Turbo Forms & Drag and Drop in Ruby on Rails: Part 1
Much like the Drag and Drop Uploader I built, I've been finding that you don't need to use JS plugins to build a lot of common functionality these days. It is easier to just roll your own solution. The DOM API is modern and easy to use.
In this series of posts we're going to build a Drag and Drop feature to add music tracks to playlists. The end result will look something like:
We'll be using Turbo so that means the majority of this will be done with minimal to no Javascript (and certainly no Typescript).
In this post we're focusing on setup and the new playlist form interaction, just setting everything else up for the next post which is the main drag and drop functionality.
The final repository is here.
Getting Started
I bootstrapped the demo drag and drop rails application using my Rails Starter. That means I have shadcn-ui so I get a great component library and some other defaults that'll make getting started fast. In fact, this first step took me 15 minutes. I'm not going to go over all the UI stuff but what you need to know is:
- A Playlist has_many playlist_tracks.
- A Playlist has_many tracks through playlist_tracks.
- A Track has_many playlist_tracks.
- A Track has_many playlists through playlist_tracks.
So we have 2 main models, Playlist, and Track, and they are joined by PlaylistTrack. We've got a Tracks controller and our main view is going to be to list out all the tracks with our playlists on the sidebar. You can check out the main view and browse the code at the start of the application.
Creating a New Playlist
The first feature we're going to implement is creating a new playlist. It'll look like this:
The plan is to use turbo frames for this but after the fact, I think turbo streams would be a better solution. However, using turbo_frames led to some interesting patterns to get it to work elegantly so I thought I would cover that approach and then in a later post, show how to refactor it to turbo streams.
Showing the New Playlist Form
The first step is to build a link that will drive the turbo_frame to load the new playlist form.
`app/views/tracks/index.html.erb
<%= render_button as: :link, href: new_playlist_path, data: {turbo_frame: "new_playlist"},
variant: :ghost, class: "px-2" do %>
+ Add
<% end %>
The important think about this link is that it will drive the navigation of a turbo frame with an id of new_playlist
. That means when we click on this link, the source of that frame will change to this links href
. So the next step is to add that turbo_frame
to the view.
app/views/tracks/index.html/erb
<div class="px-3"><%= turbo_frame_tag "new_playlist" %></div>
If we click on that link now we'll get an error that says Content Missing
. That's because we need to build the view that will load into that place. The important part about that view is that it contains a turbo_frame
with id of new_playlist
.
In the PlaylistsController
, the new
action will instantiate an instance of Playlist
and store it in @playlist
.
Here's what the view for that action looks like:
<%= turbo_frame_tag "new_playlist" do %>
<%= render_form_for(@playlist) do |form| %>
<div class="my-2">
<%= form.text_field :name %>
</div>
<div class="flex justify-between items-center">
<%= form.submit "Create", class: "text-sm px-2 py-1" %>
<%= render_button "Cancel", variant: :ghost, class: "text-sm px-2 py-1" %>
</div>
<% end %>
<% end %>
With that, clicking on the Add link should load this form, sans any javascript.
Submitting the New Playlist Form
There are two features we need to account for when submitting the form. The first is re-render the form with validation errors if the playlist doesn't contain a name.
The create
action in the PlaylistsController
will handle the validation, re-rendering our new.html.erb
if it fails validation along with sending the correct status code. If the form is valid, we can render the create.html.erb
instead of the normal flow of redirecting. The reason why we'll do this is to create the next feature, which is to show the newly created playlist within a turbo frame.
app/controllers/playlists_controller.rb
def create
@playlist = Playlist.new(playlist_params)
render :new, status: 422 unless @playlist.save
end
Validation works now because the form field is getting an error
class attached to it on the field that fails validation from form_for
(or render_form_for
with shadcn
). The error
class is defined in shadcn
and is just a red border.
The next step is to render the create.html.erb
view. We want the newly created playlist to render within the list of playlists in the view. To do this we'll use another turbo frame in the view to show all the playlists.
app/views/playlists/create.html.erb
<div class="px-3"><%= turbo_frame_tag "new_playlist" %></div>
<div dir="ltr" class="relative px-1">
<div class="h-full w-full rounded-[inherit]">
<div style="min-width: 100%; display: table">
<%= turbo_frame_tag "playlists" do %>
<div data-controller="playlists">
<%= render collection: Playlist.all, partial: "playlists/playlist" %>
</div>
<% end %>
</div>
</div>
</div>
By having turbo_frame_tag "playlists"
in the index view, if we include a similar playlist turbo frame in the create.html.erb
view, it will render the newly created playlist in the list of playlists and replace the playlists frame.
app/views/playlists/create.html.erb
<%= turbo_frame_tag "playlists" do %>
<%= render collection: Playlist.all, partial: "playlist" %>
<% end %>
The effect works pretty well.
The only issue is that the form for the playlist persists after the playlist is created instead of disappearing.
I can think of a lot of ways to solve this without reaching for Stimulus, the most proper being turning the form into a turbo stream and using append and remove directives. But I found an interesting way of doing it that doesn't bother me too much but will probably bother a good amount of people.
Inline Javascript to the Rescue
This is what I did, don't judge me.
app/views/playlists/create.html.erb
<%= turbo_frame_tag "playlists" do %>
<%= render collection: Playlist.all, partial: "playlist" %>
<script>
document.querySelector("form#new_playlist").remove()
</script>
<% end %>
That's right. I added an inline script tag in create.html.erb that does exactly what I want it to do, it removes the new_playlist
form. I can't really think of a problem with this approach other than the eww factor.
Look, when you use turbo stream directives, you're still adding behavior in the form of html tags to your html. You're still essentially calling that JS because there is no magic, you're just doing it through the turbo stream abstraction. You are still referring to some DOM element by ID, you're just doing it through the turbo stream name. This is just a more direct way of saying this view comes with instructions.
The dangers of this sort of code, such as the document not being ready or the element not existent don't apply in this use-case. It works really well, it's simple, it's direct, I'm buying it.
Update: Nate Matykiewicz correctly states that the issue with inline code has to do with Content Security Policies not style.
Wrapping Up New Playlist
With that we're able to create new playlists through a nifty form and we've setup the view and the models for the actual functionality we're interested in, the drag and drop. Unfortunately, this post got a little long and the next part is long too so I'm going to publish this as part 1 and we'll continue the drag and drop implementation in part 2.
Subscribe to my newsletter
Read articles from Avi Flombaum directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Avi Flombaum
Avi Flombaum
I'm an engineer, educator, and entrepreneur that has been building digital products for over 20 years with experience in startups, education, technical training, and developer ecosystems. I founded Flatiron School in NYC where I taught thousands of people how to code. Previously, I was the founder of Designer Pages. Currently I'm the Chief Product Officer at Revature.