turbo-frame inside a Modal
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeLet's do one more big thing with the frame system. Go to the product admin page and click to add a new product. In the last tutorial, we used Stimulus to open this in a modal, make this form submit via Ajax inside the modal, make the modal close on success and then reload the list with Ajax. An entire experience with no full page refreshes.
The stimulus controller for this lives at assets/controllers/modal-form_controller.js
. This openModal()
is called when we click to add a new product: it opens the modal and makes an Ajax call to populate that modal with the form HTML. The submitForm()
is called when the form is submitted and its job is to Ajax-submit the form and close the modal on success.
We're revisiting this example because, by leveraging Turbo frames, I think we can simplify this... like, a lot. And you can probably guess how: we can use a turbo frame to load the initial contents of the modal and to make the form submit stay in the modal.
Refactoring to a turbo-frame
The modal's markup lives in templates/_modal.html.twig
and this is meant to be reusaable in multiple places. This modal-body
element holds the actual content.
Let's transform this into a <turbo-frame>
. To keep things usable, set the frame's src=""
to a new modalSrc
variable that we will pass into this template.
<div | |
class="modal fade" | |
tabindex="-1" | |
aria-hidden="true" | |
data-modal-form-target="modal" | |
> | |
// ... lines 7 - 14 | |
<turbo-frame | |
class="modal-body" | |
data-modal-form-target="modalBody" | |
data-action="submit->modal-form#submitForm" | |
src="{{ modalSrc }}" | |
> | |
{{ modalContent|default('Loading...') }} | |
</turbo-frame> | |
// ... lines 23 - 32 | |
</div> |
Now open the template for the product admin list page: templates/product_admin/index.html.twig
. There's a lot going on here: we activate the modal-form
Stimulus controller here. We also have a Stimulus controller for reload-content
. It's job was to reload the product list after the modal closed successfully. We're going to be removing a lot of this stuff soon.
What I want to focus on right now is down here where we include that modal. Pass in that new modalSrc
variable set to path('product_admin_new)
because that's the page that holds the "new product form" that we want.
{% extends 'base.html.twig' %} | |
{% block title %}Product index{% endblock %} | |
{% block body %} | |
<div | |
class="container-fluid container-xl mt-4" | |
{{ stimulus_controller('reload-content', { | |
url: path('product_admin_index', { ajax: 1 }) | |
}) }} | |
data-action="modal-form:success->reload-content#refreshContent" | |
> | |
// ... lines 13 - 15 | |
<div | |
{{ stimulus_controller('modal-form', { | |
formUrl: path('product_admin_new') | |
}) }} | |
> | |
<button | |
class="btn btn-primary btn-sm" | |
data-action="modal-form#openModal" | |
>+ Add new product</button> | |
{{ include('_modal.html.twig', { | |
modalTitle: 'Add a new Product', | |
modalSrc: path('product_admin_new'), | |
}) }} | |
</div> | |
// ... lines 31 - 37 | |
</div> | |
{% endblock %} |
Before we try this, let's delete some code in modal-form_controller.js
. In openModal()
, we don't need to set the innerHTML
to "Loading" - that can live directly in the frame - and... we don't need to manually make an Ajax call at all! That's going to happen automatically just because we're setting the src
attribute on the <turbo-frame>
.
Also submitForm()
... yea, we're not going to need this at all. The turbo frame will handle the form submit all on its own. And thanks to these changes, one of the targets up on top - modalBody
- is no longer used. So we can remove that too.
// ... lines 1 - 4 | |
export default class extends Controller { | |
static targets = ['modal']; | |
static values = { | |
formUrl: String, | |
} | |
modal = null; | |
connect() { | |
useDispatch(this); | |
} | |
async openModal(event) { | |
this.modal = new Modal(this.modalTarget); | |
this.modal.show(); | |
} | |
} |
Yup, the job of this controller is getting... pretty simple!
Back in _modal.html.twig
, to finish our cleanup, we don't need the modalBody
target... and we also don't need the data-action
that called the submitForm
method that we just deleted.
// ... lines 1 - 14 | |
<turbo-frame | |
class="modal-body" | |
src="{{ modalSrc }}" | |
> | |
{{ modalContent|default('Loading...') }} | |
</turbo-frame> | |
// ... lines 21 - 32 |
Forgetting the id Attribute
Ok team: let's try this! Refresh the page. Hmm, nothing happened. In the console, whoa!
Failed to execute
querySelector
on element:turbo-frame#
is not a valid selector.
What is that? Well, it's not a great error, but something is looking for a turbo-frame
with a certain id - that's this #
part. But oh! I forgot to give our frame an id! Whoops.
Head back to _modal.html.twig
. I want to keep this dynamic because different modals may need different frame ids. So say id="{{ id }}"
.
// ... lines 1 - 14 | |
<turbo-frame | |
class="modal-body" | |
src="{{ modalSrc }}" | |
id="{{ id }}" | |
> | |
{{ modalContent|default('Loading...') }} | |
</turbo-frame> | |
// ... lines 22 - 33 |
Over in index.html.twig
, pass in the new id
variable set to product-info
. That's the id
we've been using... and it really could be anything, as long as it matches a frame on the new product page.
// ... lines 1 - 24 | |
{{ include('_modal.html.twig', { | |
modalTitle: 'Add a new Product', | |
modalSrc: path('product_admin_new'), | |
id: 'product-info', | |
}) }} | |
// ... lines 31 - 41 |
Ok: let's keep trying. Refresh and add a new product. Error!
Response has no matching
<turbo-frame id="product-info">
element.
Ah, I remember. In edit.html.twig
, we added a <turbo-frame>
there... but we never added the <turbo-frame>
in new.html.twig
. We could just move the turbo-frame
into _form.html.twig
because that's included on both pages. The disadvantage is that we added the frame in edit.html.twig
on purpose so that our inline editing feature would include the "edit product" h1
tag and the delete button. So instead, let's just add the same <turbo-frame>
over here in new.html.twig
.
// ... lines 1 - 4 | |
{% block body %} | |
<div class="container mt-4"> | |
<a href="{{ path('product_admin_index') }}"><i class="fas fa-caret-left"></i> Back to list</a> | |
<h1 class="mt-3">Create new Product</h1> | |
<turbo-frame id="product-info" target="_top"> | |
{{ include('product_admin/_form.html.twig') }} | |
</turbo-frame> | |
</div> | |
{% endblock %} |
Attempt number 3! Refresh and click. Got it!
Using Real Buttons vs Modal Footer
But if we try to submit this... error!
Error invoking action
click->modal-form#submitForm
.
Ok, so something is still trying to call the submitForm()
method that we deleted a few minutes ago. In _modal.html.twig
, this is coming from the modal-footer
. In this last tutorial, we added a button down here to submit the form. But this button is actually outside of the form, which lives in the turbo-frame
. What we need to do, yet again, is simplify. Remove the modal-footer
entirely.
<div | |
class="modal fade" | |
tabindex="-1" | |
aria-hidden="true" | |
data-modal-form-target="modal" | |
> | |
<div class="modal-dialog"> | |
<div class="modal-content"> | |
<div class="modal-header"> | |
<h5 class="modal-title">{{ modalTitle }}</h5> | |
<button type="button" class="btn-close" | |
data-bs-dismiss="modal" | |
aria-label="Close"></button> | |
</div> | |
<turbo-frame | |
class="modal-body" | |
src="{{ modalSrc }}" | |
id="{{ id }}" | |
> | |
{{ modalContent|default('Loading...') }} | |
</turbo-frame> | |
</div> | |
</div> | |
</div> |
If you refresh and open the form... the footer buttons are gone... but there is now no submit button on the form! Well, there is one, but it's hiding: you can see it if you inspect element and do some digging. Yup, we hid this button in the last tutorial when it's inside a modal via CSS so that the modal-footer buttons could take precedence. Now, we're going to undo that so that our form is perfectly boring and normal: a form... with a button.
Open assets/styles/app.css
and search for modal-body
. Delete this section.
Try the modal again... and... it works! And it's so boring, I absolutely love it. Try to submit the form. Um, well... that did work, but it submitted the whole page! Next, let's fix this, make the modal load lazily and delete even more code from the modal system.