Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

[FrameworkBundle] Add AbstractController::handleForm() helper#40799

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged

Conversation

@dunglas
Copy link
Member

@dunglasdunglas commentedApr 13, 2021
edited by chalasr
Loading

QA
Branch?5.x
Bug fix?no
New feature?yes
Deprecations?no
Ticketsn/a
LicenseMIT
Doc PRsymfony/symfony-docs#15217

Some libraries such as Turbo require to strictly follow the HTTP specification (and especially to use proper status codes) to deal with forms.

In#39843, I introduced a newrenderForm() helper for this purpose. But I'm not very satisfied by it. The current approach has several problems:

  1. It calls$form->isValid() two times, which may hurt performance
  2. It sets the proper status code in case of validation error (422), but not for the redirection when the entity is created or updated (306). The user must do this manually (and so be aware of these HTTP subtleties).
  3. It hides the verbosity of the Form component a bit, but I'm a sure that we can reduce it more

This PR proposes an alternative helper,handleForm(), which handles automatically 80% of the use cases, provide an extension point for the other 20%, and can also serve as a quick example for users to handle form in a custom way (by control-clicking on the function to see the code and copy/paste/adapt it).

  • if the form is not submitted, the Twig template passed in $view is rendered and a 200 HTTP status code is set
  • if the form is submitted but invalid, the Twig template passed in $view is rendered and 422 HTTP status code is set
  • if the form is submitted and valid, the entity is saved (only if it is managed by Doctrine ORM), a 306 HTTP status code is set and the Location HTTP header is set to the value of $redirectUrl

Before (standard case):

    #[Route('/{id}/edit', name:'conference_edit', methods: ['GET','POST'])]publicfunctionedit(Request$request,Conference$conference):Response    {$form =$this->createForm(ConferenceType::class,$conference);$form->handleRequest($request);$submitted =$form->isSubmitted();$valid =$submitted &&$form->isValid();if ($valid) {$this->getDoctrine()->getManager()->flush();return$this->redirectToRoute('conference_index', [], Response::HTTP_SEE_OTHER);        }$response =$this->render('conference/edit.html.twig', ['conference' =>$conference,'form' =>$form->createView(),        ]);if ($submitted && !$valid) {$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);        }return$response;    }

With the new helper:

    #[Route('/{id}/edit', name:'conference_edit', methods: ['GET','POST'])]publicfunctionedit(Request$request,Conference$conference):Response    {$form =$this->createForm(ConferenceType::class,$conference);return$this->handleForm($request,$form,            view:'conference/edit.html.twig',            redirectUrl:$this->generateUrl('conference_index')        );    }

Before (more advanced use case):

    #[Route('/{id}/edit', name:'conference_edit', methods: ['GET','POST'])]publicfunctionedit(Request$request,Conference$conference,HubInterface$hub):Response    {$form =$this->createForm(ConferenceType::class,$conference);$form->handleRequest($request);$submitted =$form->isSubmitted();$valid =$submitted &&$form->isValid();if ($valid) {$this->getDoctrine()->getManager()->flush();$hub->publish(newUpdate('conference:'.$conference->getId(),$this->renderView('conference/edit.stream.html.twig', ['conference' =>$conference])                )            );return$this->redirectToRoute('conference_index', [], Response::HTTP_SEE_OTHER);        }$response =$this->render('conference/edit.html.twig', ['conference' =>$conference,'form' =>$form->createView(),        ]);if ($submitted && !$valid) {$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);        }return$response;    }

With the new helper (more advanced case):

    #[Route('/{id}/edit', name:'conference_edit', methods: ['GET','POST'])]publicfunctionedit(Request$request,Conference$conference,HubInterface$hub):Response    {$form =$this->createForm(ConferenceType::class,$conference);$response =$this->handleForm($request,$form,            view:'conference/edit.html.twig',            redirectUrl:$this->generateUrl('conference_index')        );if ($response->isRedirection()) {$hub->publish(newUpdate('conference:' .$conference->getId(),$this->renderView('conference/edit.stream.html.twig', ['conference' =>$conference])                )            );        }return$response;    }

This also works without named parameters. I also considered passing a callback to be executed on success, but I'm happier with the current solution.

WDYT?

TODO:

  • update tests

chapterjason, houssemz, QuentG, TomasVotruba, and kbond reacted with thumbs up emoji
@chalasr
Copy link
Member

I think we should make the persistence part optional

apfelbox reacted with thumbs up emoji

@dunglasdunglasforce-pushed thefeat/simpler-form-handling branch from2a83b57 to89dff58CompareApril 13, 2021 16:32
@dunglas
Copy link
MemberAuthor

Here is a new closure-based proposal based on the feedback in this PR:

    #[Route('/{id}/edit', name:'conference_edit', methods: ['GET','POST'])]publicfunctionedit(Request$request,Conference$conference):Response    {return$this->handleForm($request,$this->createForm(ConferenceType::class,$conference),function (FormInterface$form)use ($conference) {$this->getDoctrine()->getManager()->flush();return$this->redirectToRoute('conference_show',                    ['id' =>$conference->getId()],                    Response::HTTP_SEE_OTHER                );            },function (FormInterface$form) {return$this->render('conference/edit.html.twig', ['form' =>$form->createView(),                ]);            }        );    }
chapterjason reacted with thumbs up emoji

@dunglasdunglasforce-pushed thefeat/simpler-form-handling branch 3 times, most recently fromb23fe89 toccb9694CompareApril 13, 2021 16:40
Copy link
Member

@nicolas-grekasnicolas-grekas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

I like this iteration :)

deguif, dunglas, jesperveldhuizen, chapterjason, and chalasr reacted with thumbs up emoji
@zmitic
Copy link

Question:

wouldn't it be better ifonSuccess callback also receivesConference entity?

Reasons:

  • when the form is valid, it is more likely we need data from form, not form itself. Form can be second parameter.
  • static analysis: we still can't use generics likeFormInterface<Conference> $form. Even if we could, it would still returnnullable value
  • it also makes things easier when Conference is created viaempty_data like this:
$this->createForm(ConferenceType::class)// no $data

Otherwise we have to write this:

function (FormInterface$form) {/** @var Conference $conference */$conference =$form->getData();// 2 extra lines needed, with @var as tricking SA tools// ...},

instead of simpler:

function (FormInterface$form,Conference$conference) {// ...},
  • more on static analysis; it would be trivial to add stubs forhandleForm callbacks; psalm would detect incorrect signature early in the controller.

Similar forrender callback but nullable Conference also in callback.

@plandolt
Copy link
Contributor

Based on#40799 (comment) I'd like to suggest this changes:

    #[Route('/{id}/edit', name:'conference_edit', methods: ['GET','POST'])]publicfunctionedit(Request$request,Conference$conference):Response    {return$this->handleForm(            FormHandler::create($request)                ->withFormType(ConferenceType::class)                ->withFormData($conference)                ->withHandleRequestCallback(function (FormInterface$form)use ($conference) {$this->getDoctrine()->getManager()->flush();return$this->redirectToRoute('conference_show',                        ['id' =>$conference->getId()],                        Response::HTTP_SEE_OTHER                    );                })                ->withCallback(function (FormInterface$form) {return$this->render('conference/edit.html.twig', ['form' =>$form->createView(),                    ]);                })            ;        );    }
  • Using a builder object keeps it more flexible as the 4 parameters solution. This is also more verbose which I think will help developers using this way of form handling.
  • This will also allow to enhance the public api of the FormBuilder object in the future without dealing with named parameters or parameter order.
  • Naming of course up in the air :)

WDYT?

Jurigag reacted with thumbs up emoji

@nicolas-grekas
Copy link
Member

nicolas-grekas commentedApr 13, 2021
edited
Loading

@zmitic this preserves the type info and works already:function (FormInterface $form) use ($conference) {, no need for adding as argument IMHO.

@scuben I'd be 👎 on my side: this would require a pile of code for something that arguments already provide.

@zmitic
Copy link

zmitic commentedApr 13, 2021
edited
Loading

@nicolas-grekas

this preserves the type info and works already: function (FormInterface $form) use ($conference) {, no need for adding as argument IMHO

It doesn't when Conference is created withempty_data; check the example, there is no Conference before the callback.

Full example tocreate entity from data in form:https://symfony.com/doc/current/form/use_empty_data.html#option-2-provide-a-closure

    #[Route('/create', name:'conference_create', methods: ['GET','POST'])]publicfunctioncreate(Request$request):Response    {return$this->handleForm($request,$this->createForm(ConferenceType::class),// null for $datafunction (FormInterface$form) {/** @var Conference $conference */$conference =$form->getData();// extra 2 lines, one used to trick static analysis// the rest            },function (FormInterface$form) {return$this->render('conference/edit.html.twig', ['form' =>$form->createView(),                ]);            }        );    }

UPDATED:

Most likely users will use one file for bothcreate andedit and have a title. For this case,render callback should also get Conference:

function (FormInterface$form, ?Conference$conference) {return$this->render('conference/form.html.twig', ['form' =>$form->createView(),// changing page title via reusable ``form.html.twig``'page_title' =>$conference ?'Editing' .$conference->getName() :'Create new conference',     ]);}

@nicolas-grekas
Copy link
Member

Oh btw, I'd be fine also with@zmitic's proposal to pass the form's data as 2nd argument to the closures.

@bOmBeLq
Copy link

bOmBeLq commentedApr 15, 2021
edited
Loading

Would service instead of method in controller class be considerable?

 #[Route('/{id}/edit', name: 'conference_edit', methods: ['GET', 'POST'])]    public function edit(Request $request, Conference $conference, FormHandlerFactoryInterface $formHandlerFactory): Response    {        return $formHandlerFactory            ->create(SomeType::class, $conference, $typeOptions)            ->setTemplate($template, $params)            /// or            ->setRenderCallback(function($conference) {... })            ->onSuccess(function($conference ) {return $this->redirectToRoute(...)})            ->handle( $request)    }

could even get request form request stack in service or would it be bad practice?

@dunglas
Copy link
MemberAuthor

dunglas commentedApr 16, 2021
edited
Loading

I swapped the$form and$request arguments.

Regarding@zmitic's proposal, it will not be necessary anymore with#40783 (I described why here:#40783 (comment)) so I suggest keeping the code simple for now and to not add a new argument.

Regarding the service, I don't think it is worth it yet. It's just a tiny helper method.

I think that this PR is now ready to be merged.

nicolas-grekas and chalasr reacted with thumbs up emoji

Copy link
Member

@chalasrchalasr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

Once the corresponding changelog entry is updated.

@zmitic
Copy link

@dunglas Is it possible to rediscuss this decision? Template forFormInterface::getData() returns?T (i.e. nullable), that's is why I asked to add the value to callbacks.

If there was a method likegetDataOrThrowException(): T, it wouldn't be a problem.

I already made stubs for almost entire form component,including FormInterface.

Current usage that allows psalm to know the type:

/** @extends AbstractType<User> */class UserTypeextends AbstractType{}

Real example from my current project, relevant parts are selected:

image

Even events are covered, example for PRE_SET_DATA:

image

All of them depends onFormInterface::getData(): ?T.

@dunglas
Copy link
MemberAuthor

@zmitic I'm not sure to follow. Adding the proper types to the closures in#40783 should do the trick, isn't it? The data passed in the closure would be nullable too or am I missing something?

@zmitic
Copy link

zmitic commentedApr 16, 2021
edited
Loading

Adding the proper types to the closures in#40783 should do the trick, isn't it?

It won't for forms.FormInterface::getData() returns nullable type which is correct. So to remedy the issue, users will have to add 2 extra lines if $data is needed (and most likely will).

Let me refer to this:

will allow to not introduce "useless" code just to have autocompletion working properly

My argument wasn't about autocompletion but about static analysis. With LSP+psalm, I already have autocompletion using generics:

image

Notice that there is nouse App\Entity\User\User; on top, yet autocomplete works without any help from PHPStorm itself.


I use psalm on level 1 for almost two years so I am ready to help if needed. I am not skilled enough to contribute to Symfony core itself but for this idea, I am 99% sure.

Let me know if you need more examples.

@dunglas
Copy link
MemberAuthor

But the data passed to the closure could benull to isn't it? So I don't understand how it will help regarding static analysis.

@zmitic
Copy link

@dunglas

But the data passed to the closure could be null to isn't it?

If the form is valid, it means that value willnot be null so users will typehintConference $data instead of?Conference $data.

AndonSuccess is calledonly when form is valid so it will not be null, and users willnot need to typehint nullable value.

UnlikeFormInterface::getData() which always return nullable value, and force us to write those 2 extra lines.

Side note:
To avoid any confusion; psalm plugin for PHPStorm is not working. It doesn't recognize anything more than most simple generics so I had to turn it off.

However LSP works great. It does have few bugs but once you learn how to avoid them, it is absolute joy to work with generics and having autocomplete for them.

@zmitic
Copy link

Let me explain with example. The code inhandleFormis this:

if ($submitted &&$form->isValid()) {return$onSuccess($form);}

what I am proposing is this:

if ($submitted &&$form->isValid() &&$data =$form->getData()) {return$onSuccess($form,$data);}

with this change, we could write code like this:

$this->handleForm(    onSuccess:function (FormInterface$form,Conference$conference) {// we can render form, and we KNOW that $conference is an entity, NOT null   });

but without that second parameter:

$this->handleForm(    onSuccess:function (FormInterface$form) {/** @var Conference $conference */$conference =$form->getData();         });

The second example requires 2 extra lines. And that@var annotation is only to trick psalm; without it, even with stubs, psalm would think it is nullable Conference because of FormInterface.


I hope it is more clear now, if not, I will provide more.

@dunglas
Copy link
MemberAuthor

If the form is valid, it means that value will not be null so users will typehint Conference $data instead of ?Conference $data.

Here is what the interface says:

    /**     * Returns the model data in the format needed for the underlying object.     *     * @return mixed When the field is not submitted, the default data is returned.     *               When the field is submitted, the default data has been bound     *               to the submitted view data.     *     * @throws Exception\RuntimeException If the form inherits data but has no parent     */    public function getData();

AFAIU it's not guaranteed by the interface that anull value will not be returned even if it's what does the default implementation provided by the Form component. This new helper method depends on the interface, not on the implementation, so we cannot be sure that the data will not be null.

I've no strong opinion on this TBH. If we pass the data object as a second parameter, then we should also clarify the interface to make it clear thatnull will never be returned after a submission. But is it really the case in all existing implementations? I don't know this component enough to decide here. WDYT @symfony/mergers?

@Neirda24
Copy link
Contributor

In my opinion it returnsmixed so any value can be returned and can considered valid.null as well as[] or0 for that matter. Adding an opinionated check on this directly in the framework would be quite a risk. If it is a helper it shouldn't do anything more than to help people. And some people will just go by submitter & valid and will never check if their data is null or not. Because it might be (for some odd reason I agree) considered valid ?

@zmitic
Copy link

This new helper method depends on the interface

Exactly, my stubs cover FormInterface, AbstractType and others; I was super-careful to not makeany problems, took me lots of time to make them. Even had to annoy muglug many timesvimeo/psalm#4136 😄

Typehintmixed in original code is put because generics where not even an idea before, but now we can use them.

And some people will just go by submitter & valid and will never check if their data is null or not.

This is also one of the reasons why I ask for this change. If we could use

$this->handleForm(    onSuccess:function (FormInterface$form,Conference$conference) {// we can render form, and we KNOW that $conference is an entity, NOT null   });

there would never be any confusion; at this point, weknow it is not null, no need for@var orinstance of. But without second parameter: extra code to trick psalm.

@dunglas
Copy link
MemberAuthor

Ok got it. This makes sense. +1 for me. Thanks for taking the time to explain this in depth@zmitic!

zmitic reacted with hooray emoji

@zmitic
Copy link

@dunglas Glad I could help and sorry if I didn't explain it simpler.

Once merged, I will add stubs forhandleForm. With them, it would be possible to detect incorrect usage in controllers.

Example would be usingUserType but typehintingCustomer.

Copy link
Member

@ycerutoyceruto left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

LGTM, thanks!

@nicolas-grekasnicolas-grekasforce-pushed thefeat/simpler-form-handling branch from3db80ca to5228546CompareApril 16, 2021 16:31
@nicolas-grekas
Copy link
Member

Thank you@dunglas.

dunglas reacted with hooray emoji

Sign up for freeto join this conversation on GitHub. Already have an account?Sign in to comment

Reviewers

@stofstofstof requested changes

@nicolas-grekasnicolas-grekasnicolas-grekas approved these changes

@ycerutoycerutoyceruto approved these changes

@chalasrchalasrchalasr approved these changes

@weaverryanweaverryanAwaiting requested review from weaverryan

@wouterjwouterjAwaiting requested review from wouterj

Assignees

No one assigned

Projects

None yet

Milestone

5.4

Development

Successfully merging this pull request may close these issues.

10 participants

@dunglas@chalasr@zmitic@plandolt@nicolas-grekas@Neirda24@bOmBeLq@stof@yceruto@carsonbot

[8]ページ先頭

©2009-2025 Movatter.jp