The Generic Modal

The Generic Modal
Photo by Jens Peter Olesen / Unsplash

What is a modal even? How could it be generic? Questions seems to pile easily. I'll do my best to answer them as we build exactly that - a generic modal.

I'm using the SaaS product we're building – it's called M O R T I M E R and it affords you the fastest from job done to invoice sent.

It's a Rails 8 application but you could strap this on a Rails 7 application as well, I believe.

How the generic modal presents itself — when tasked with deleting something

A modal

Modals are a graphical control element subordinate to an application's main window according to Wikipedia. Usually it's implemented with a backdrop indicating that focus has now shifted to the modal element only.

Javascript fellows prompt and alert displays modals of sorts. We can go quite further than that but we will start there.

Modals will have 3 variables defining their capabilities:

Flavor

We will say that modals have a flavor. From very basic to very advanced flavors will tell us what the modal will be capable of. alert is one flavor that only is capable of posting a 'warning sign'. prompt is just a bit more advanced affording a accept or refuse reply - think deleting posts etc. guide is – at the moment – the most advanced modal I can think of. It provides information, allows for inputting information, and may advance forward or go backwards in a preset number of views/steps.

Modal_form

Modal form partials are labeled by their name in the views/modal/ folder - like fx '_delete.html.erb'

Modal_next_step

The last piece of information is of a "cursory nature" – it points to the next expected user action. Like 'accept' will set things in motion, and ‘step_1’ will move to just that.

Modals in the Hotwired context

We will build the generic modal aided by Hotwire – if this is "new territory" to you go study it by all means; I'm pretty sure you'll like it!

We will need somewhere to place the modal content so our first test must verify that we do allow for the content to be 'placeable'

require "application_system_test_case"

class ModalTest < ApplicationSystemTestCase
  setup do
    @tenant = tenants(:one)
    @user = users(:superadmin)
  end

  test "visiting the index" do
    login_as(@user)
    visit root_url
    # assert <remote-modal-container></remote-modal-container>
    assert_match "remote-modal-container", page.html
  end
end

This custom tag <remote-modal-container> will allow us to inject HTML into the DOM using Turbo.

From some link that you'd like to open a modal - fx confirm a delete action - we would like to inject HTML. Turbo will 'by default' fetch ie use AJAX to request the endpoint / the HTML and inject it into the DOM.

The link could look like this

# views/components/context_menu.rb
class Contextmenu < Phlex::HTML
  include Phlex::Rails::Helpers::Request
  include Phlex::Rails::Helpers::Routes
  include Phlex::Rails::Helpers::LinkTo
  include Phlex::Rails::Helpers::ButtonTo
  include Rails.application.routes.url_helpers

  attr_accessor :resource, :resource_class, :list

  def initialize(resource: nil, list: nil, resource_class: nil, turbo_frame: "_top", alter: true, links: [], cls: "relative flex")
    @resource = resource
    @resource_class = resource_class || resource.class
    @list = list
    @turbo_frame = turbo_frame
    @alter = alter
    @links = links
    @css = cls
  end

  def view_template
    div(data_controller: "contextmenu", class: @css) do
      contextmenu_button
      case true
      when !list.nil?; list_dropdown
      when !resource.nil?; dropdown
      end
    end
  end

...
      resource_class.any? ?
        link2(url: helpers.new_modal_url(
          all: true,
          modal_flavor: "prompt",
          modal_form: "delete",
          modal_next_step: "accept",
          resource_class: resource_class.to_s.underscore,
          search: request.query_parameters.dig(:search)),
          action: "click->contextmenu#hide",
          icon: "trash",
          label: I18n.t(".delete_all")) :
        div(class: "disabled_context_link") { I18n.t(".delete_all") }
...


    def link2(url:, 
      label:, 
      action: nil, 
      data: { turbo_stream: true }, 
      icon: nil, 
      css: "context_link")
      
      data[:action] = action if action
      link_to url,
        data: data,
        class: css,
        role: "menuitem",
        tabindex: "-1" do
        render_icon icon
        span(class: "text-nowrap pl-2") { label }
        span(class: "sr-only") do
          plain label
          plain " "
          plain resource.name rescue ""
        end
      end
    end


    def render_icon(icon)
      return if icon.blank?
      render "Icons::#{icon.camelcase}".constantize.new(cls: "contexticon")
    end

        

If you'd like to read about using view components leave a comment 😄

The 'soup' to emerge from all of this is something a lot like this

So, when a user tap's the [delete all] context menu item Turbo will send a request that looks like this (with 'as TURBO_STREAM' being one of the essential things to note):

15:16:32 web.1  | Started GET "/modal/new?
  all=true&
  modal_flavor=prompt&
  modal_form=delete&
  modal_next_step=accept&
  resource_class=time_material" for 127.0.0.1...
15:16:32 web.1  | Processing by ModalController#new as TURBO_STREAM
15:16:32 web.1  |   Parameters: {
  "all"=>"true", 
  "modal_flavor"=>"prompt",
  "modal_form"=>"delete", 
  "modal_next_step"=>"accept", 
  "resource_class"=>"time_material"}

So next up we'll need a controller to handle the(se) endpoint(s) - again if you have questions regarding the before_actions especially the resources drop me a comment below

# controllers/modal_controller.rb
class ModalController < MortimerController
  before_action :set_vars
  before_action :set_batch
  before_action :set_filter
  before_action :set_resources
  before_action :set_resources_stream

  def new
    # resource
    @resource = find_resource
    @ids = @filter.filter != {} ||
      @batch&.batch_set? || 
      @search.present? ? 
      resources.pluck(:id) : 
      []
      
  end
  
  private

    def set_vars
      @all = params[:all] || "false"
      @modal_form = params[:modal_form]
      @modal_flavor = params[:modal_flavor]
      @modal_next_step = params[:modal_next_step] || "accept" rescue ""
      @url = params[:url] || resources_url rescue root_url
      @search = params[:search] || ""
    end

The view part of things is where the Turbo really shines. We start by adding a helper to ease the repetitive task affording us a method show_remote_modal

# helpers/turbo_stream_actions_helper.rb
module TurboStreamActionsHelper  
  def show_remote_modal(&block)    
    turbo_stream_action_tag(:show_remote_modal,
      template: @view_context.capture(&block)    
    )  
  end
end
Turbo::Streams::TagBuilder.prepend(TurboStreamActionsHelper)

That method comes to good use in the 'new' view template like this

<!-- views/modal/new.turbo_stream.erb -->
<%= turbo_stream.show_remote_modal do %>
  <dialog 
    id="new_form_modal" 
    aria-labelledby="modal_title" 
    data-controller="modal" 
    data-action="keydown->modal#keydown">

    <!-- take your modal of choice from tailwindui.com -->
    <div class="tailwindclasses">
      <div 
        class="more-tailwind-classes">
        <div class="lots-of-classes">
          <div class="absolute right-0 top-0 pr-4 pt-4 sm:block">
            <form 
              id="dialog_form" 
              method="dialog">
              <button 
                aria-label="close" 
                type="submit" 
                formmethod="dialog" 
                data-action="click->modal#close" 
                class="close_modal_button">
                <span class="sr-only">Close</span>
                <svg 
                  class="h-6 w-6" 
                  fill="none" 
                  viewBox="0 0 24 24" stroke-width="1.5"
                  stroke="currentColor" aria-hidden="true">
                  <path 
                    stroke-linecap="round" 
                    stroke-linejoin="round" 
                    d="M6 18L18 6M6 6l12 12" />
                </svg>
              </button>
            </form>
          </div>
          <div 
            id="modal_container" 
            data-action="modalClose@window->modal#closeDialog" 
            data-dialog="new_form_modal">
            
            <%= render @modal_form %>
          </div>
        </div>
      </div>
    </div>
  </dialog>
<% end %>

It's not missing per se, more like untouched until now 😀

Hotwire packs a good punch - with Turbo controlling the navigation and loading of resources, and Stimulus allowing for custom building actions. Read more about Stimulus.

We connect a javascript controller to a DOM element - the dialog element in this case:

// javascript/controllers/modal_controller.js

import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="modal"
export default class extends Controller {
  static targets = ["backdrop", "dialog", "dismiss", "submitForm"];

  connect() {
    document.documentElement.classList.add('lock-scroll');
    document.body.classList.add('lock-scroll');
  }

  disconnect() {
    this.removeScrollBlock()
  }

  keydown(e) {
    e.stopPropagation();
    switch(e.key) {
      case "Escape":
        this.close(e);
        break;
      case "Enter":
        this.submitFormTarget.click();
        break;
    }
  }

  removeScrollBlock(){
    document.documentElement.classList.remove("lock-scroll");
    document.body.classList.remove("lock-scroll");
  }

  submitForm(event) {
    this.removeScrollBlock()
  }

  show(event) {
    const dialog = document.getElementById(event.params.dialog);
    dialog.showModal();
  }  

  closeDialog(event) {
    const dialog = document.getElementById(event.target.data.dialog);
    dialog.close();
  }

  closeModal(e) {
    const modal = document.getElementById("new_form_modal");
    modal.close();
  }

  close(e) {
    this.removeScrollBlock()
  }
}

The modal is, by now, entirely generic and we can throw (almost) anything in there. In the link we decide by setting a @modal_form and a @flavor. The _new partial looks like this:

<% url = resource.id.present? ?
  modal_url(resource) : 
  modal_url(resource_class.first) %>
<%= form_with(url: url, method: :delete, multipart: true) do |form| %>
  <div class="sm:flex sm:items-start">
    <div class="tailwind-classes-galore">
      <%= render Icons::Trash.new cls: "h-6 text-red-500" %>
    </div>
    <div class="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
      <h3 
        class="text-base font-semibold leading-6 text-gray-900"
        id="modal_title">
        
        <%= delete_modal_title %>
      </h3>
      <div class="mt-2">
        <p class="text-sm text-gray-500 pb-3">
          <%== delete_modal_instruction %></p>
          <%= form.hidden_field :all, value: set_all_true %>
          <%= form.hidden_field :modal_form, value: @modal_form %>
          <%= form.hidden_field :modal_next_step, value: @modal_next_step %>
          <%= form.hidden_field :modal_flavor, value: @modal_flavor %>
          <%= form.hidden_field :resource_class, value: resource_class %>
          <%= form.hidden_field :id, value: resource&.id %>
          <%= form.hidden_field :search, value: params.dig(:search) %>
          <%= form.hidden_field :url, value: @url %>
        </p>
      </div>
    </div>
  </div>
  <div class="mt-5 sm:mt-4 flex justify-between flex-row-reverse">
    <%= form.button
      t("#resource_class.to_s.underscore}.modal.delete.button"), 
      data: { 
        modal_target: "submitForm", 
        action: "modal#submitForm"
      }, 
      class: "mort-btn-alert sm:ml-3" %>
      
    <%= button_tag t(:cancel), 
      form: "dialog_form", 
      formmethod: :dialog, 
      data: { action: "click->modal#close" }, 
      class: "mort-btn-cancel" %>
  </div>
<% end %>

This 'model_form' sets all the variables for the form and when submitted returns a request like this:

17:14:07 web.1  | Started DELETE "/modal/388" for ...
17:14:07 web.1  | Processing by ModalController#destroy as TURBO_STREAM
17:14:07 web.1  |   Parameters: {"authenticity_token"=>"[FILTERED]",
  "modal_flavor"=>"prompt", 
  "modal_form"=>"delete", 
  "modal_next_step"=>"accept", 
  "resource_class"=>"TimeMaterial", 
  "all"=>"true", 
  "attachment"=>"", 
  "id"=>"388", 
  "search"=>"", 
  "url"=>"https://localhost:3000/modal", 
  "button"=>""}

Back in the controller we handle this like so:

# controllers/modal_controller.rb
class ModalController < MortimerController
  def destroy
    params[:action] = "destroy"
    params[:all] == "true" ? process_destroy_all : process_destroy
  end


  private
 

    def process_destroy_all
      begin
        DeleteAllJob.perform_later 
          tenant: Current.tenant, 
          user: Current.user, 
          resource_class: resource_class.to_s,
          ids: resources.pluck(:id),
          batch: @batch,
          user_ids: (
            resource_class.first.respond_to?(:user_id) ?
            resources.pluck(:user_id).uniq : 
            User.by_tenant.by_role(:user).pluck(:id)) rescue nil
            
        @url.gsub!(/\/\d+$/, "") if @url.match?(/\d+$/)
        flash[:success] = t("delete_all_later")
        respond_to do |format|
          format.turbo_stream { }
          format.html { 
            redirect_to @url, 
            status: 303, 
            success: t("delete_all_later") 
          }
          format.json { head :no_content }
        end
      rescue => e
        say "ERROR on destroy: #{e.message}"
        redirect_to 
          resources_url, status: 303, error: t("something_went_wrong")
      end
    end
 
end

I'll have to save building the guide flavored modal for another day – but here you have it: a generic modal ready for display just about anything you can think of.

Enjoy

Walther H Diechmann

Walther H Diechmann

Got on the train just about when the IBM PC 5150 got introduced and never really got off again - switched to macOS about 2006 though, and never looked back. It's been such a ride!
Silkeborg, Denmark