The Generic Modal
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.

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 %>
Stimulus - the missing link
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