Injector controller: pattern for modals

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