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 SubscribeSomething isn't right. We can click this "edit" link to inline-load the product form into the Turbo Frame. But when we save, something weird happens. Watch the console closely down here. Whoa! It was fast, but it looked the Ajax request failed! And then, the whole page reloaded?
Time to put on our detective hats! Let's start by getting more information about why the form submit failed. Click any link on the web debug toolbar to jump into the profiler... and then click the "last 10" link to see the last 10 requests.
Ah, here! A 405 error. Open the profiler for that page:
No route found for POST /product/1: Method not allowed
Wait: look at the URL. That is not the right URL! The form should submit to the product admin area, which... if you navigate there, looks like this: /admin/product/12/edit
. But the form actually submitted to the public product show page. Why?
Close this tab and hit edit again. Actually, refresh, hit edit and inspect element on the form. Ah ha! The form element does not have an action
attribute. Normally this is fine! If you go to the product admin page and click to edit a product, the form doesn't have an action
attribute here either. That's ok because when a form doesn't have an action
attribute, it tells your browser to submit to the URL that it's currently on. For this page, that's perfect.
But when we're on the public product show page... and we load the same form, having that missing action
attribute is not okay: our browser incorrectly thinks it should submit to /product/1
.
Here's the takeaway: if you're planning to load a form into a turbo-frame
, that form does need an action
attribute. We can't be lazy like we normally are.
We can set the action attribute in a few places, but I like to do it in the controller where we create the form. Open the controller for the product admin area: src/Controller/ProductAdminController.php
. Right now we're only dealing with the edit page, but I'll set the action on both the new and edit actions to be safe. Add a third argument to createForm()
and pass an option called action
set to the URL to this action: $this->generateUrl('product_admin_new')
.
Now scroll down to the one that we really care about: the edit action. Same thing here: pass a third argument with action
set to $this->generateUrl('product_admin_edit')
... but this needs an id
wildcard set to $product->getId()
.
... lines 1 - 31 | |
/** | |
* @Route("/new", name="product_admin_new", methods={"GET","POST"}) | |
*/ | |
public function new(Request $request): Response | |
{ | |
$product = new Product(); | |
$form = $this->createForm(ProductType::class, $product, [ | |
'action' => $this->generateUrl('product_admin_new'), | |
]); | |
$form->handleRequest($request); | |
... lines 43 - 62 | |
/** | |
* @Route("/{id}/edit", name="product_admin_edit", methods={"GET","POST"}) | |
*/ | |
public function edit(Request $request, Product $product): Response | |
{ | |
$form = $this->createForm(ProductType::class, $product, [ | |
'action' => $this->generateUrl('product_admin_edit', [ | |
'id' => $product->getId(), | |
]), | |
]); | |
$form->handleRequest($request); | |
... lines 74 - 101 |
Time to give this a try! Refresh the page, click edit, change the title and submit the form. Very nice... kind of. If you scroll down to find this product... yes! It did update the title!
But, as we can see, it redirected to the product admin list page, not the product show page. When we click this "edit" button, that does load the form into the Turbo frame. But then, because the frame has target="_top"
, when we submit the form, it submits to the whole page and navigates the whole page. That's why hitting save redirects us to a totally different page.
And that's maybe okay: this is already a better experience than when we started. But we could make it a bit more awesome by redirecting back to the public product show page. Let's try that: I'll do it in just the edit action. On success, change the index route to app_product
- the route for the show page - and pass this the id
wildcard that it needs.
... lines 1 - 73 | |
if ($form->isSubmitted() && $form->isValid()) { | |
$this->getDoctrine()->getManager()->flush(); | |
return $this->redirectToRoute('app_product', [ | |
'id' => $product->getId(), | |
]); | |
} | |
... lines 82 - 103 |
Let's see how this feels. Open up the floppy disk public show page, hit edit, change the title and submit. That's very nice!
Edit the product again, but empty the title so that we fail validation. When we submit now, this navigate us away from the show page and puts us in the admin section. That makes complete sense: we know that the form is still submitting to the full page, not to the frame. And so, again, this is probably okay! We should probably stop and say "good enough!".
Or... we could also make the form submit in the frame.
To do this, we have two options. Over in show.html.twig
, we have target="_top"
on the turbo-frame
. The first way that we could make the form submit to the frame would be to remove this target so that everything navigates inside the frame. Of course, if we did that, we would need to make sure to add data-turbo-frame="_top"
to any links or forms that should target the full page.
The other option is to leave the target="_top"
and then, on just the product form, add data-turbo-frame="product-info"
.
For me, the best option is still... not totally clear. Is it better to add target="_top"
on the frame and then target the frame on individual links and forms? Or should we leave target="_top"
off the frame and add target="_top"
to the individual links and forms that need it?
I don't have a perfect answer. But my rule of thumb is to determine this based on the main purpose of a frame. In this case, I would expect most links to navigate the whole page, so the target="_top"
on the frame feels safer.
So let's go change the target of just the form. The edit page template is edit.html.twig
, but the form lives in _form.html.twig
. Pass a second argument to form_start
with an attr
variable set to an object. Inside that, set data-turbo-frame
to product-info
.
{{ form_start(form, { | |
attr: { 'data-turbo-frame': 'product-info' } | |
}) }} | |
{{ form_widget(form) }} | |
<button class="btn btn-primary" formnovalidate>{{ button_label|default('Save') }}</button> | |
{{ form_end(form) }} |
Let's try the flow! Refresh. We have a turbo-frame
with target="_top"
... but inside, an edit link that specifically targets the frame. When we click this, the new form is still in the frame with target="_top"
... but it also targets the product-info
frame.
Thanks to this, if we empty the title and submit... woohoo! That keeps us on the page! That submitted into the frame. And if we put the title back, change it and submit. Beautiful!
Next: when we submit a form inside a frame... and that request redirects to another page, what happens? Does that redirect the entire page and change the URL in the address bar? Or does it only update the frame? Let's find out and fix a related bug with our new inline edit frame system.
"Houston: no signs of life"
Start the conversation!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"composer/package-versions-deprecated": "1.11.99.1", // 1.11.99.1
"doctrine/annotations": "^1.0", // 1.13.1
"doctrine/doctrine-bundle": "^2.2", // 2.3.2
"doctrine/orm": "^2.8", // 2.9.1
"phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
"sensio/framework-extra-bundle": "^6.1", // v6.1.4
"symfony/asset": "5.3.*", // v5.3.0-RC1
"symfony/console": "5.3.*", // v5.3.0-RC1
"symfony/dotenv": "5.3.*", // v5.3.0-RC1
"symfony/flex": "^1.3.1", // v1.18.5
"symfony/form": "5.3.*", // v5.3.0-RC1
"symfony/framework-bundle": "5.3.*", // v5.3.0-RC1
"symfony/property-access": "5.3.*", // v5.3.0-RC1
"symfony/property-info": "5.3.*", // v5.3.0-RC1
"symfony/proxy-manager-bridge": "5.3.*", // v5.3.0-RC1
"symfony/runtime": "5.3.*", // v5.3.0-RC1
"symfony/security-bundle": "5.3.*", // v5.3.0-RC1
"symfony/serializer": "5.3.*", // v5.3.0-RC1
"symfony/twig-bundle": "5.3.*", // v5.3.0-RC1
"symfony/ux-chartjs": "^1.1", // v1.3.0
"symfony/ux-turbo": "^1.3", // v1.3.0
"symfony/ux-turbo-mercure": "^1.3", // v1.3.0
"symfony/validator": "5.3.*", // v5.3.0-RC1
"symfony/webpack-encore-bundle": "^1.9", // v1.11.2
"symfony/yaml": "5.3.*", // v5.3.0-RC1
"twig/extra-bundle": "^2.12|^3.0", // v3.3.1
"twig/intl-extra": "^3.2", // v3.3.0
"twig/string-extra": "^3.3", // v3.3.1
"twig/twig": "^2.12|^3.0" // v3.3.2
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.0
"symfony/debug-bundle": "^5.2", // v5.3.0-RC1
"symfony/maker-bundle": "^1.27", // v1.31.1
"symfony/monolog-bundle": "^3.0", // v3.7.0
"symfony/stopwatch": "^5.2", // v5.3.0-RC1
"symfony/var-dumper": "^5.2", // v5.3.0-RC1
"symfony/web-profiler-bundle": "^5.2", // v5.3.0-RC1
"zenstruck/foundry": "^1.10" // v1.10.0
}
}
// package.json
{
"devDependencies": {
"@babel/preset-react": "^7.0.0", // 7.13.13
"@fortawesome/fontawesome-free": "^5.15.3", // 5.15.3
"@hotwired/turbo": "^7.0.0-beta.5", // 1.2.6
"@popperjs/core": "^2.9.1", // 2.9.2
"@symfony/stimulus-bridge": "^2.0.0", // 2.1.0
"@symfony/ux-chartjs": "file:vendor/symfony/ux-chartjs/Resources/assets", // 1.1.0
"@symfony/ux-turbo": "file:vendor/symfony/ux-turbo/Resources/assets", // 0.1.0
"@symfony/ux-turbo-mercure": "file:vendor/symfony/ux-turbo-mercure/Resources/assets", // 0.1.0
"@symfony/webpack-encore": "^1.0.0", // 1.3.0
"bootstrap": "^5.0.0-beta2", // 5.0.1
"chart.js": "^2.9.4",
"core-js": "^3.0.0", // 3.13.0
"jquery": "^3.6.0", // 3.6.0
"react": "^17.0.1", // 17.0.2
"react-dom": "^17.0.1", // 17.0.2
"regenerator-runtime": "^0.13.2", // 0.13.7
"stimulus": "^2.0.0", // 2.0.0
"stimulus-autocomplete": "https://github.com/weaverryan/stimulus-autocomplete#toggle-event-always-dist", // 2.0.0
"stimulus-use": "^0.24.0-1", // 0.24.0-2
"sweetalert2": "^11.0.8", // 11.0.12
"webpack-bundle-analyzer": "^4.4.0", // 4.4.2
"webpack-notifier": "^1.6.0" // 1.13.0
}
}