Hotwiring Modals

Hotwiring Modals

In my previous post I ended on kind of an "up key" with job done – which with hindsight probably was a bit exaggerated. I never got to mention Caleb Porzio who created AlpineJS.

app/views/accounts/_modal.html.haml is not ripe for generalization yet but this is what it looks like:


:coffeescript

  window.modalData = () ->
    {
      modalOpen: false,
      dispatcher: null,
      toggleModal: () ->
        @modalOpen = ! @modalOpen 
      initModal: (w,d) ->
        @dispatcher = d
        document.body.classList.add 'overflow-hidden'
        w 'modalOpen', (value) =>
          if (value == true)
            document.body.classList.add 'overflow-hidden'
          else
            document.body.classList.remove 'overflow-hidden'
      sendModal: () ->
        @modalOpen = ! @modalOpen
      openModal: (e) ->
        @dispatcher 'opened-event-modal',''
        @toggleModal()
        if e.detail!=''
          el = document.getElementById('account_modal_form')
          el.innerHTML = e.detail
          el.style.zIndex = '99'
      closeModal: () ->
        @dispatcher 'closed-event-modal',''
        @modalOpen = ! @modalOpen
    }

.fixed.inset-0.z-10.grid.place-content-center.justify-items-center{ :"x-data"=>"modalData()", :"x-init"=>"initModal($watch,$dispatch)", :"x-show"=>"modalOpen", :"@open-event-modal.window"=>"openModal", :"@close-event-modal.window"=>"closeModal()" }
  .absolute.inset-0.bg-black.opacity-10
  / %span.hidden.sm:inline-block.sm:align-middle.sm:h-screen( aria-hidden="true") ​
  #account_modal_form
    = render partial: "form", locals: { account: @account, header: t('.konto') }

The first (real) line of HTML beginning with .fixed.inset-0 holds these constructs:

:"x-data"=>"modalData()" – which scopes the reach of variables and methods to this DOM element and children

:"x-init"=>"initModal($watch,$dispatch)" – initializes scoped variables and if you need access to stuff like a dispatcher from your JS this is the proper place to announce it  

:"x-show"=>"modalOpen" – will show/hide this DOM element depending upon the truthiness of modalOpen

:"@open-event-modal.window"=>"openModal" – this is how eventListeners look like using AlpineJS.

:"@close-event-modal.window"=>"closeModal()" – label the event and attach .window and define what should happen.


With the modal sitting duck we just need a ways to call it up. I believe to have left off just about setting the rows right in my previous post, but I had to refactor that part (more will come, I anticipate):

.table-row.bg-white{"x-description" => "Odd row", id:  dom_id(account) }
  .table-cell.px-6.py-4.whitespace-nowrap.text-sm.cursor-pointer.font-medium.text-gray-900{ :"@click" => "openModal('#{edit_account_path(account)}')"}
    = account.name
  .hidden.lg:table-cell.px-6.py-4.whitespace-nowrap.text-sm.text-gray-500
    /= #link_to account.participant.name, 
  .hidden.md:table-cell.px-6.py-4.whitespace-nowrap.text-sm.text-gray-500
    = account.service_plan
  .hidden.sm:table-cell.px-6.py-4.whitespace-nowrap.text-sm.text-gray-500
    \-
  .table-cell.px-6.py-4.whitespace-nowrap.text-right.text-sm.font-medium
    = link_to 'Destroy', account, method: :delete, data: { confirm: 'Are you sure?' }

There's at least one more post after this one – this pattern begs a kind of componentization and my first target will be ViewComponents possibly married to Stimulus.

First, however, we will continue finishing the naive implementation with alpineJS, Turbo, and Hotwire. Check out the previous post if you'd like to see how I managed to get the 'new account'  working with a modal.

In the above code-block .table-cell.px-6.py-4.whitespace-nowrap.text-sm.cursor-pointer.font-medium.text-gray-900{ :"@click" => "openModal('#{edit_account_path(account)}')"} is about the only really interesting thing. It tells AlpineJs to go look for a method called openModal when the user clicks somewhere on the row.

I've put that – and a couple others – in the top of the view:


  :coffeescript 

    window.indexState = () ->
      {
        dispatcher: null,
        initModal: (d) ->
          @dispatcher = d
        openModal: (url) ->
          d = @dispatcher
          if url!=''
            fetch url
              .then (response) ->
                response.text()
              .then (response) ->
                d 'open-event-modal', response
        editModal: (id) -> 
          @dispatcher 'open-event-modal', id
        closeModal: () ->
          @dispatcher 'close-event-modal',''
      }

I woun't hold it against you if this is confusing! I'll admit to not labelling the methods to afford easy access but once you understand the scoping of AlpineJS I at least find it quite sensible to label methods alike – they will work in unison to get the job done after all.

There's one more 'openModal' in the section header affording the 'Ny konto' (new account)


:coffeescript

  window.sectionData = () ->
    {
      dispatcher: null,
      init: (d) ->
        @dispatcher = d
      openModal: () ->
        d = @dispatcher
        fetch '/accounts/new'
          .then (response) ->
            response.text()
          .then (response) ->
            d 'open-event-modal', response
      closeModal: () ->
        @dispatcher 'closed-event-modal',''
    }


.bg-white{ :"x-data" => "sectionData()", :"x-init" => "init($dispatch)"}
  .px-4.py-12.max-w-7xl.mx-auto.sm:px-6.lg:px-8
    .pb-5.border-b.border-gray-200.sm:flex.sm:items-center.sm:justify-between
      %h1.text-2xl.font-semibold.font-display.text-gray-900.sm:text-3xl.ml-5
        = section_title
      .mt-3.sm:mt-0.sm:ml-4
        %label.sr-only{:for => "search_candidate"}= section_search
        .flex.rounded-md.shadow-sm
          .relative.flex-grow.focus-within:z-10
            .absolute.inset-y-0.left-0.pl-3.flex.items-center.pointer-events-none
              %svg.h-5.w-5.text-gray-400{"aria-hidden" => "true", :fill => "currentColor", :viewbox => "0 0 20 20", "x-description" => "Heroicon name: solid/search", :xmlns => "http://www.w3.org/2000/svg"}
                %path{"clip-rule" => "evenodd", :d => "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", "fill-rule" => "evenodd"}
            %input#search_candidate.focus:ring-indigo-500.focus:border-indigo-500.w-full.rounded-none.rounded-l-md.pl-10.sm:block.sm:text-sm.border-gray-300{:name => "search_candidate", :placeholder => section_search, :type => "text"}/
          %button.-ml-px.relative.inline-flex.items-center.px-4.py-2.border.border-green-300.text-sm.font-medium.rounded-r-md.text-green-800.bg-green-400.hover:bg-green-200.focus:outline-none.focus:ring-1.focus:ring-green-500.focus:border-green-500{ type: "button", :"@click" => add_new_logic }
            = add_new_btn_title

You may notice how I've started "componentizing" the section_header using variables to labels and methods like section_title and add_new_logic – we'll revisit this in another post most certainly.

This partial gets called from the `/app/views/accounts/index.html.haml` like

= render partial: "shared/section_heading", locals: { section_title: t('.title'), section_search: t('.search'), add_new_logic: "openModal()", add_new_btn_title: t('.new') }

All there is left is to adjust the controller. I've moved the renders into templates

  # POST /accounts or /accounts.json
  def create
    @participant_type = params[:account].delete :participant_type
    @account = Account.new(account_params)
    respond_to do |format|
      if @account.save
        @account.set_participant @participant_type 
        format.turbo_stream 
        format.html { redirect_to accounts_url, notice: "Account was successfully created." }
        format.json { render :show, status: :created, location: @account }
      else
        format.turbo_stream 
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @account.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /accounts/1 or /accounts/1.json
  def update
    respond_to do |format|
      if @account.update(account_params)
        format.turbo_stream
        format.html { redirect_to accounts_url, notice: "Account was successfully updated." }
        format.json { render :show, status: :ok, location: @account }
      else
        format.turbo_stream
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @account.errors, status: :unprocessable_entity }
      end
    end
  end

The /app/views/accounts/create.turbo_stream.haml will act on the result of the save

- if @account.errors.any?
  = turbo_stream.replace "new_account", partial: "accounts/form", locals: { account: @account, header: t('.konto') }
- else
  = turbo_stream.replace "new_account", partial: "accounts/form", locals: { account: Account.new, header: t('.konto') }

whereas the /app/views/accounts/update.turbo_stream.haml will do - well almost verbatim the same?!?

- if @account.errors.any?
  = turbo_stream.replace "new_account", partial: "accounts/form", locals: { account: @account, header: t('.konto') }
- else
  = turbo_stream.replace "new_account", partial: "accounts/form", locals: { account: Account.new, header: t('.konto') }
  = turbo_stream.replace( @account )

I'm positively sure this does not even make it to the first beta – for one because it does not really take advantage of the Turbo(stream); it's little more than a bunch of JavaScript incantations not qualifying for much 'turbo' :)

We'll see –