Frames & Form "action" Attributes

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Something 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.

Setting the Form action

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.

Redirecting to the Product Show 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!".

Submitting the Form in the Frame

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.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=7.4.0",
        "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.13.3
        "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
    }
}