Injector controller: pattern for modals

Hey all, wondering if anyone could give me a sanity check on an approach I’ve been spiking out.

Context: Rails/Turbolinks enabled

Situation: when a user clicks on a button, I want to show a modal that renders content.

I found two approaches that I wasn’t really happy with:

  • Render a hidden (with css) modal in my Rails templates directly, create a Stimulus controller to show/hide when a button fires an action – I dislike this approach because I don’t like shoving extra stuff into the DOM, imagine if you had 3-4 different modals you might need to show from a given page, maybe the modal needs to make different/extra queries to get content
  • Try using some third-party javascript library (bootstrap-modal, tingle, etc) and wrap it with Stimulus – seems overkill (I don’t really need a whole library for a modal) and I don’t know of an easy way to pass in the modal content

A third approach is one I tried and wanted some input on: an injector controller.

Globally register the whole body for this controller and have the button trigger an action and store some arguments in dataset

<body data-controller="injector"> 
  ...
  <button data-action="injector#inject" data-component="modal" data-url="/posts/new" />
</body>

Then the injector controller manages injecting components into the DOM – these components can and probably should be other Stimulus components

import { Controller } from "stimulus";

export default class extends Controller {
  connect() {
    this.injectedComponents = [];

    document.addEventListener(
      "turbolinks:before-cache",
      () => {
        this.teardown();
      },
      { once: true }
    );
  }

  inject(event) {
    const data = event.currentTarget.dataset;
    const node = document.createElement("div");

    switch (data.component) {
      case "modal":
        node.innerHTML = `
          <div class="modal">
            <div class="modal-content">
              <div data-controller="content-loader"
                   data-content-loader-url="${data.url}"></div>
            </div>
          </div>
        `;

        document.body.append(node);
        this.injectedComponents.push(node);
        break;
      // repeat for other 'dynamic' components as necessary?
      default:
        throw ("Unknown injector component: ", data.component);
    }
  }

  teardown() {
    this.injectedComponents.forEach(i => {
      document.body.removeChild(i);
    });
  }
}

(Note: content-loader is from the docs: https://stimulusjs.org/handbook/working-with-external-resources)

Any feedback? Too much indirection? Too much magic? Am I not thinking in the “Stimulus mindset” and doing something really dumb?

Thanks!

You said, that you are using Rails. In that case I would probably go with Server-generated JavaScript Response (SJR) + Stimulus. You can render the modal in the backend, inject it into the DOM with SJR and use Stimulus to manage it. Something like the following.

Use rails-ujs to create an Ajax link:

# app/views/users/index.html.erb

<%= link_to "New User", new_user_path, remote: true %>
# app/controllers/users_controller.rb

class UsersController < ApplicationController
  def new
    @user = User.new
  end
end

Insert your modal into the DOM:

# app/views/users/new.js.erb

document.body.insertAdjacentHTML("beforeend", "<%= j render "modal" %>");
# app/views/users/_modal.html.erb

<div data-controller="modal">
  ...
</div>

Shameless plug: I’ve wrote some articles about SJR, you can check them here: https://m.patrikonrails.com/. Also, please let me know if you have any questions regarding this method.

Ah – thanks! I did forget about SJR as an option, that does seem to fit the bill.

hey hey hey!

I happen to have written a gem that solves this exact problem! It uses SJR. And it doesn’t have any dependencies. It doesn’t use Stimulus because Stimulus wasn’t a thing then.

With it, you could to write an SJR response such as

Popup("<%=j render 'modal' %>").show("up")

to display your modal partial in a modal dialog.

I hope you’ll find it useful, @swanson

:gem: https://github.com/colorfulfool/server-generated-popups

I agree that your modal should be requested when needed and rendered on the server to take advantage of ActionView.

But … I never liked using JQuery in any server response. Specifically I think of modals as html views and not javascript views. And the client framework should know how and where to render those html views (like a browser does with a full page load). See below for an example.

# app/views/widgets/index.html.erb

<%= link_to "New Widget", new_widget_path, rel: "modal:ajax" %>
# app/controllers/widgets_controller.rb

def new
  @widget = Widget.new
  render layout: "modal"
end
# app/assets/javascripts/modals/ajax.js

$(document).ready(function() {
  $(document).on("click", "*[rel='modal:ajax']", function(event) {
    $.ajax({
      url: $(this).attr('href'),
      success: function(modalHTML, textStatus, jqXHR) {
        $(modalHTML).appendTo('body').modal();
      }
    });
    event.preventDefault();
  });
});

I also like this approach because it gives me more client-side control over loading/error/other states when requesting a modal from the server … and it looks and acts similar to how I would implement this as a stimulus controller.

1 Like

For those reading this now, this problem can now be simply solved with turbo-frames from the turbo-rails gem and a little bit of stimulus js.

This approach seems to work well for me:

# some view
<%= turbo_frame_tag "new_group" do %>
   <%= link_to "New group", new_group_path  %>
<% end %>

  # groups/new.html.erb
  <%= turbo_frame_tag "new_group" do %>
    <div data-controller="modal">
         <----- insert the modal stuff here ----->
    </div>    

         <-------- If the modal is cancelled you still may want to access the form again ----->
          <%= link_to "New group", new_group__path%>
  <% end %>

We can initialise the modal with a simple stimulus js controller:

// modal_controller.js
import { Controller } from "stimulus";

export default class extends Controller {
  connect(){       
    $(this.element).modal();
  }

  disconnect() {    
	$(this.element).modal('hide');	  // hide if we cancel
  }
}

In my case I’m using bootstrap with jQuery - so obviously you’ll want to handle that.

This allows for a complex work flow - i can create a new group within another form while still being on the same page.

I might have missed something - apologies if i have. hope this helps.

1 Like

Right. Very useful as the whole page will not get replaced as before but only the frame.

What about populating the frame via the server? For example, editing an existing form record within a modal. I’ve been trying to use a stream to send down the HTML but it is not happening for me. Did you do this?

I, too, am trying to perfect a “use modal for new/edit posts” pattern - and so far this is what I’ve got (and I do apologise for the mix of Erb/HAML templating). I like the FAB [fixed action button] so my ambition is to keep the posts#index in focus and inject @post into the modal. My posts_controller.rb is almost identical to Doug Stull’s - so go enjoy his nice work!

In all of the following - one thing is unsolved (and one of the reasons I post this here, hoping to get some feedback telling me where I go wrong): when I click the ‘add’ fab button - the browser address line changes to /posts/new. That’s all. Otherwise, I’m pretty happy with the functionality - but that URL rewrite really is a tough one to crack IMHO.

I tend to start my templating with a _body.html.erb and this one has a global script affording the opening of the modal from all over the place

# /app/views/application/_body.html.erb
<script>
  // open recordModal
  window.openRecordModal = function(){
    rm = document.getElementById('recordModal')
    rm.__x.$data.showModal = true
    setTimeout(function(){ rm.querySelectorAll('input.autofocus')[0].focus() }, 250)
  }
</script>

<body>
  <main class="flex-1 relative overflow-y-auto focus:outline-none" tabindex="0">
    <div class="py-6">
      <%= yield %>
    </div>
  </main>
  <%= yield :fab %>
</body>

The :yield in the _body will make room for the posts/index template. I’ve skipped some of all the TailwindCSS - it’s totally cp’ed from TailwindUI anyway. I like AlpineJS for ‘sprinkling’ (maybe when I’m a bit more fluent in Stimulus I’ll go with that all the way)

# /app/views/posts/index.html.haml 
:javascript

  function fab(){
    return {
      show() { openRecordModal() }
    }
  }
  function posts(){
    return {
      show() { openRecordModal() }
    }
  }

= turbo_stream_from "posts"

- content_for :fab do
  .fixed-action-btn( x-data="fab()" )
    %a( href="#{new_post_path}" x-on:click="show()" type="button" class="inline-flex...")
      %i.material-icons add

  = render "posts/modal", post: @post

= turbo_frame_tag "posts" do
  = render @posts

The next template carries on where the previous ends - allowing for rendering each post.

# /app/views/posts/_post.html.erb
<%= turbo_frame_tag dom_id(post) do %>
    <li class="md:flex justify-between items-center">
      <p><a href="<%=edit_post_path(post)%>"  x-on:click="show()" ><%= post.title %></a></p>
    </li>
    <li>
        <hr class="my-5 border-t border-gray-300" />
    </li>
<% end %>

Further up into the index template the modal template is called with render "posts/modal", post: @post and that modal is next

<script>

  function recordModal() {
    return {
      showModal: false,
      open() { this.showModal = true },
      close() { this.showModal = false },
      is_open() { return this.showModal === true },
    }
  }

</script>

<!--Overlay-->
<div 
  id="recordModal"
  x-data="recordModal()"
  @keydown.escape="close()" 
  x-cloak 
  x-show="showModal" 
  :class="{ 'absolute inset-0 z-10 flex items-center justify-center': showModal }"
  data-controller="modal" 
  data-modal-target="card" 
  class="overflow-auto" 
  style="background-color: rgba(0,0,0,0.5)" 
  > 
  <!--Dialog-->
  <div @click.away="close()" class="bg-white..." > 
    <!--less important part of the modal -->

    <%= render "form", post: @post %>

  </div>
</div>

And finally the form

<%= turbo_frame_tag 'post_form' do %>
  <%= form_with(model: post, id: dom_id(post), data: { "modal-target": "form", action: 'turbo:submit-end->modal#reset' }, class: "space-y-8 divide-y divide-gray-200" ) do |form| %>
     <div class="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
       <div class="sm:col-span-4">
         <%= form.label :title, class: "block text-sm font-medium text-gray-700" %>
         <div class="mt-1 flex rounded-md shadow-sm">
           <%= form.text_field :title, class: "autofocus flex-1 focus:ring-indigo-500 focus:border-indigo-500 ck w-full min-w-0 rounded-none rounded-r-md sm:text-sm border-gray-300" %>
         </div>
       </div>
     </div>
  <% end %>
<% end %>

Ups - I almost forgot the stimulus controller

import { Controller } from "stimulus"

export default class extends Controller {
  static targets = ["card","form"]

  reset() {
    document.getElementById('recordModal').__x.$data.showModal = false
    this.formTarget.reset()
  }
}
2 Likes

Yes the frame is populated entirely by the server.

I’ve managed to successfully do this with a form to create a new record - but the approach could just as easily be applied to editing a post (or in the example used above, a new Group record). (Pls note that I do have client side validation to prevent form errors being pushed to the server).

The above is a concise summary, of what i have written up here: creating a new record via a modal using turbo-frames

1 Like

Thanks @BKSpurgeon. I did get server side population working => Routing in a modal interface - #6 by minimul.

1 Like