I find it difficult to use Turbo Frames: building a simple modal to edit items

I am an experienced Rails developer, but I have never used Hotwire/Turbo. I am trying to understand it and I find it extremely convoluted.

Let’s take a simple example:

  1. You have a list of items
  2. The user clicks the edit button near an item
  3. A modal with a form is opened to edit the item
  4. The user clicks save on the form
  5. (a) If the form contains some errors the errors are displayed inside the modal (without closing it)
  6. (b) If the form is correct, then the modal should be closed and the items displayed in the list are updated

You can open the edit form in a frame and use Stimulus to display that frame. However when you save the form you would need to conditionally render a frame or another based on a condition: on success you update the “list” frame, on failure you update the errors in the “form” frame. You can’t do that with Turbo Frames…

So, how would you solve the above workflow using Hotwire?

Probably I need to use Turbo Streams to apply the different changes on form submit (instead of using the simpler Turbo Frames for this use case).

In any case if someone more expert with Hotwire can suggest the high level solution/approach that they would use in this case, it would be appreciated.

Hi Marco, yes, indeed, you definitely have to use at least streams or channel. This is a big missing point in docs. In the end you have three options:

  1. Simple turbo response that just changes a frame and does nothing else.
  2. Turbo streams that let you do multiple turbo actions
  3. Channel (web-socket) that is completely independent and can coexist with 1. (or 2. but that makes no sense.)

I spent endless time trying to understand this. You can check READMES from my gem where this is described.

Or take the time to go through hotrails.dev, which explains these basics in great detail, but is a lot of text.

I may be missing something here, but I think you can return a frame with the form in case of error or a stream with action=remove target=modal-id?

There’s a couple of ways you can do it. It depends on if you want to allow multiple modals at the same time. I chose the simpler option of only allowing one modal.

I initially render an empty turbo frame, (and a turbo stream tag too) within my layout file:

<%= turbo_frame_tag "modal" %>
<%= turbo_stream_from online_newsletter %>

I then have a button somewhere that kicks off the modal:

<%= link_to 'Add Article', [:new, :admin, online_newsletter, :article],
  data: { turbo_frame: 'modal' } %>

The server then responds with something like the following:

<%= turbo_frame_tag "modal" do %>
  <div class="modal is-active" data-controller="modal">

    <div class="modal-background"></div>
    <%= form_with model: article, scope: :article, url: [:admin, online_newsletter, :articles],
      class: 'modal-card', data: { action: 'turbo:submit-start->modal#beginSubmit turbo:submit-end->modal#handleSubmit' } do |form| %>

<!-- modal content, form fields etc... -->

      <footer class="modal-card-foot">
        <button class="button is-success" data-modal-target="submitButton">Add</button>
        <button class="button" data-action="click->modal#deactivate" type="button">Cancel</button>
      </footer>
    <% end %>
  </div>
<% end %>

No magic required up to this point as when you render the view (with no layout) wrapped in that turbo_frame_tag it just replaces the contents of that turbo frame. I think you can even render a layout if you want and Turbo will ignore it.

The next part is just some wiring up with stimulus though you can probably achieve a result without it. I use stimulus to basically delete the modal element from the page which ‘closes’ it. You could return a response with an empty turbo_frame_tag which would have the same effect. Using stimulus allows you to do more fancy things like prevent from submission unless conditions are met, and prevent the closure of the modal with a warning about unsaved content and so on. This is my modal controller:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ['submitButton']

  connect() {
    document.documentElement.classList.add('is-clipped')
  }

  deactivate() {
    this.close()
  }

  beginSubmit() {
    this.submitButtonTarget.classList.add('is-loading')
    this.submitButtonTarget.disabled = true
  }

  handleSubmit(event) {
    this.submitButtonTarget.classList.remove('is-loading')
    this.submitButtonTarget.disabled = false

    if (event.detail.success) { this.close() }
  }

  close() {
    document.documentElement.classList.remove('is-clipped')
    this.element.remove()
  }
}

I’ve trimmed it up a bit as I have extra stuff for disabling submit if trix attachments are still uploading and also some stuff to track if the form has had edits (in which case it won’t let the user close the modal or leave the page without saving or acknowledging).

Here’s my controller action for create:

def create
  respond_with article do |format|
    if article.save
      format.turbo_stream { }

      Turbo::StreamsChannel.broadcast_append_to online_newsletter,
        target: 'articles', html: render_to_string(partial: 'online_newsletters/article')
    else
      format.html { render 'new', status: :unprocessable_entity }
    end
  end
end

I’ve tried to clean that up a bit but basically I wanted to broadcast my updates to everyone working on the newsletter, so I use a Turbo Streams channel to do that. You could just as easily render out a turbo streams append HTML tag to only update the current user’s page. In that case you’d do that inside of format.turbo_stream { }. In the error scenario rails just renders the form again with the errors outlined and the modal appears to update with this new content. It’s seamless enough that it just looks like some javascript has updated the particular fields to add error states but in reality the whole modal has been replaced.

Some caveats to the above:

I hope that helps. I did find it took quite a while to get my head around all the differences @chris31 described, especially because the documentation isn’t overly user friendly, but once I had things settled in my mind it was quite easy to do some pretty powerful things with very little javascript.

3 Likes