DEV Community

loading...

How to use modals with forms in Rails using Turbo

dstull profile image Doug Stull Updated on ・5 min read

Goal

Using the Hotwire, show how to setup a basic implementation of a modal with a form.

We'll break this down into talking about the controller, view templates and javascript.

There are many other things that were setup in the example application due to the choices made in the tech stack and some setup/configuration has been derived from things learned on Go Rails. We'll assume some familiarity with Stimulus in order to keep focused on the benefits of Turbo.

Source code for this guide - updated to turbo-rails 0.5.9

Posts page being referenced

Screen Shot 2021-01-07 at 11.21.43 PM

Creating a new post using the modal

New link and modal setup

Note this github comment is what I used as guide for this setup.

This all starts in index.html.erb

<%= turbo_frame_tag 'post' %>

<div class="mt-8" data-controller="post-modal" data-post-modal-prevent-default-action-opening="false">
  <%= render partial: 'posts/modal_form' %>
...
<%= link_to 'New Post', new_post_path, class: "btn btn-primary", data: { action: "click->post-modal#open", 'turbo-frame': 'post' } %>
Enter fullscreen mode Exit fullscreen mode
  • turbo_frame_tag allows us to tell turbo that links with a matching data-turbo-frame value matching it will return an html response including a matching frame tag(not sure on this wording, as we supply it here to merely tell turbo to leave the url unchanged when the link is clicked). Our action upon New Post click will be a replace that is defined in the new.html.erb template. This will populate the modal via use of the target value on the turbo-stream element in the template.
  • render our modal_form partial which will contain the modal definition and define a div with an id that the turbo-stream response replaces.
  • data-controller="post-modal" names our post-modal controller and causes it to connect on render.
  • data-post-modal-prevent-default-action-opening="false" will allow us to open the modal with the link and also fetch new_post_path from the controller and render our modal content.

Clicking the New Post link

This action will take us into the posts_controller.rb.

def new
  @post = Post.new
end
Enter fullscreen mode Exit fullscreen mode
<%= turbo_frame_tag 'post' do %>
  <turbo-stream target="post_form" action="replace">
    <template>
      <%= render partial: "posts/form", locals: { post: @post } %>
    </template>
  </turbo-stream>
<% end %>
Enter fullscreen mode Exit fullscreen mode
  • We render the turbo_stream response inside the template with a target that matches the id inside the modal. This will then replace that element inside the modal with our form content.
  • Below is an example response

Screen shot

  • While the data is being fetched, the modal is given the signal to open, so we'll listen for an event from another Stimulus controller. That controller is the post_form_controller.js, and it keeps us from seeing unprepared html and stale data from an invalid form submission.
  • We can achieve that by adding an eventListener to post_modal_controller.js's open function.
document.addEventListener("postForm:load", () => {
  this.containerTarget.classList.remove(this.toggleClass);
});
Enter fullscreen mode Exit fullscreen mode
  • The above is done in order to take advantage of the connectCallback that seems to be the only way to know when the replace action from turbo-stream is completed.

Rendering the response

At this point the modal that is defined in _modal_form.html.erb is opening and this element below is being replaced with the turbo-stream response that has the rendered form.

<%= tag.div nil, id: 'post_form' %>
Enter fullscreen mode Exit fullscreen mode

Screen Shot 2021-01-07 at 11.34.34 PM

Submitting the form

When we submit the form, the modal closes via close function in the extended post-modal stimulus controller and reach the create method in the posts_controller.rb

def create
  @post = Post.new(post_params)

  respond_to do |format|
    if @post.save
      format.html { redirect_to posts_url, notice: 'Post was successfully created.' }
    else
      format.turbo_stream do
        render turbo_stream: turbo_stream.replace('post_form',
                                                    partial: "posts/form",
                                                    locals: { post: @post })
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode
  • If successful, we'll just redirect to the page the modal is launched from, and flash a message. This will all feel seamless since Turbo takes care of replacing items in place; negating the need for a full page reload.
  • When the form submission is not successful, turbo_stream format is rendered and the form is replaced(leaving the modal open and adding in the errors automatically)

Screen Shot 2021-01-07 at 11.44.33 PM

Editing a post using the modal

This uses the same concepts as the new submission and starts off in index.html.erb as seen below.

<div class="align-middle min-w-full overflow-x-auto shadow overflow-hidden sm:rounded-lg" data-controller="post-modal" data-post-modal-prevent-default-action-opening="false">
  <table class="min-w-full divide-y divide-cool-gray-200">
    <thead>
    <tr>
      <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-cool-gray-500 uppercase tracking-wider">Title</th>
      <th class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-cool-gray-500 uppercase tracking-wider">Body</th>
      <th colspan="1" class="px-6 py-3 bg-gray-50 text-left text-xs leading-4 font-medium text-cool-gray-500 uppercase tracking-wider"></th>
    </tr>
    </thead>
    <tbody class="bg-white divide-y divide-cool-gray-200">
    <% @posts.each do |post| %>
      <tr>
        <td class="px-6 py-4 whitespace-nowrap text-sm leading-5 text-cool-gray-900">
          <%= post.title %>
        </td>
        <td class="px-6 py-4 whitespace-nowrap text-sm leading-5 text-cool-gray-500">
          <%= post.body %>
        </td>
        <td class="px-6 py-4 whitespace-nowrap text-sm leading-5 text-cool-gray-500">
          <%= link_to 'Edit', edit_post_path(post), class: "btn btn-secondary", data: { action: "click->post-modal#open", 'turbo-frame': 'post' } %>
        </td>
      </tr>
    <% end %>
    </tbody>
  </table>
</div>
Enter fullscreen mode Exit fullscreen mode

The rest of edit and delete can be found in the example application

In closing

Disclaimer: The code above is far from perfect

In some places the implementation could definitely use refinement(feel free to suggest an improvement).

I chose a modal here as I had that particular issue to solve on a project and didn't see a ready-made solution/guide.

Overall I am happy with what Turbo enables and appreciate how the hard work of others to build these types of things, make it easier for everyone else to produce things quickly.

Discussion (13)

Collapse
matiascarpintini profile image
Matias Carpintini

I don't like the fact of render edit partial on each iteration :/

Collapse
dstull profile image
Doug Stull Author

Yep...that could likely be refactored to only put in one edit modal for entire page as an optimization

Collapse
dstull profile image
Doug Stull Author

I've fine tuned this a bit to only edit one modal - could likely go further and make it one modal skeleton/post-modal stimulus controller instance for entire page if desired. Let me know what you think - gitlab.com/doug.stull/turbo_modal

Thread Thread
dstull profile image
Doug Stull Author

I've further refactored it now to only render a modal skeleton once and utilize that for both new and edit and updated this post - thanks for the nudge in a better direction!

Thread Thread
matiascarpintini profile image
Matias Carpintini

Do you know how we can run some JS when form appends? I'm using selectize, the first time works with turbo:loads, but not then because turbo just fires when the history changes :/

Thread Thread
dstull profile image
Doug Stull Author

I don’t know how...yet. I had same issue with wanting to trigger showing the modal when it was finished rendering. In my case I hacked it with a 200ms timeout in the post-modal controller. Maybe someone will come along with an a solution.

Thread Thread
matiascarpintini profile image
Matias Carpintini

Hahaha yes, I think that too but it didn't works in slow conections

Thread Thread
dstull profile image
Doug Stull Author • Edited

So I have an initial work-around - not what I'd consider a final solution(it works), so I opened an Merge Request while I think about it more - should likely try to figure out what magic connect is using instead of creating a new stimulus controller.

gitlab.com/doug.stull/turbo_modal/...

edit:

And here is more of the turbo way to do it, though I have some reservations that it is the event I need to listen to...will have to try initializing something JS wise like a select element to feel confident in this approach

gitlab.com/doug.stull/turbo_modal/...

Thread Thread
matiascarpintini profile image
Matias Carpintini

This is my temporary solution (it sucks, i know 😂):

$(document).on("turbo:before-fetch-response", function(){
var checkExist = setInterval(function () {
if ($('.selectize#item_category_ids').length) {
$(".selectize#item_category_ids").selectize({
create: function (input, callback) {
$.ajax({
method: "POST",
url: "/categories.json",
data: { category: { name: input } },
success: function (response) {
// call callback with the new item
callback({ value: response.id, text: response.name });
}
});
},
});
clearInterval(checkExist);
}
}, 100);
});

Thread Thread
dstull profile image
Doug Stull Author • Edited

I don't think it is that bad actually.

However, I think this is the correct solution

see my latest commit on gitlab.com/doug.stull/turbo_modal/...

reasoning:

  • from this line it looks like connect is the only thing that is sure that the stream as finished rendering.

See if that works for you?

Thread Thread
matiascarpintini profile image
Matias Carpintini

Awesome, thank you!

Thread Thread
dstull profile image
Doug Stull Author

Thank you, for providing great feedback that helped with the iteration here! I tried this solution with slim select and of course the customary console.log debugging and it seems to work for me, so I merged it and updated the blog post.

Collapse
dstull profile image
Doug Stull Author • Edited

I've updated the code used in this post with the latest release of turbo. I had to change how things loaded a bit and rely on turbo include tag to load turbo correctly. Webpack loading was having issues with any update of turbo-rails past 0.5.3.

Latest commit shows the updates gitlab.com/doug.stull/turbo_modal/...

Forem Open with the Forem app