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 –