Turbo Forms & Drag and Drop in Ruby on Rails: Part 1

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:

Drag and Drop Playlist

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:

New Playlist

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

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.

Adding a Playlist

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.

Did you find this article valuable?

Support Avi Flombaum by becoming a sponsor. Any amount is appreciated!