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?

3 Likes

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

I am also a rail Developer. Thanks for asking this question.

@collimarco Great question! I’ve been on the lookout for examples and documentation as I to believe its too convoluted. I really wouldn’t mind going back to ajax lol.

With the release of Turbo 8 and page refreshes with morphing, I believe it might not be necessary to use individual Turbo Streams to update the page after a form fill.

This would involve adding to your model class:

broadcasts_refreshes

which will broadcast a page refresh to any page listening to the model instance when it is updated.

On the view side, you can subscribe to these broadcasts by adding to your page/partial:

<%= turbo_stream_from @model %>

and adding to your layout head:

<%= turbo_refresh_method_tag :morph %>
<%= turbo_refresh_scroll_tag :preserve %>

This approach has the added benefit of updating the page whenever the model is changed by any session for everyone, and updating whenever any property is changed, not just the one you explicitly define.

Here are some helpful articles on this:

1 Like