Stimulus Discourse

Injector controller: pattern for modals


#1

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!


#2

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.


#3

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


#4

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


#5

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.