Skip to content

Navigation Menu

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

Appearance settings

[Form] Add MultiStepType #59548

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 our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 16 commits into from

Conversation

silasjoisten
Copy link
Contributor

@silasjoisten silasjoisten commented Jan 18, 2025

Q A
Branch? 7.3
Bug fix? no
New feature? yes
Deprecations? no
Issues -
License MIT

This PR introduces the MultiStepType form type, which allows for the creation of form flows. The idea for this form type was initially proposed in a PR for Symfony UX, where it was suggested that the type would be better suited within the Symfony Form component.

But this form works also without Symfony UX.

Usage:

declare(strict_types=1);

namespace App\Form;

use Symfony\Component\Form\Extension\Core\Type\MultiStepType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\IsTrue;
use Symfony\Component\Validator\Constraints\NotBlank;
use App\Form\AuthorType;

final class MyFancyWizardType extends AbstractType
{
    /**
     * @return class-string<AbstractType>
     */
    public function getParent(): string
    {
        return MultiStepType::class;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'steps' => [
                'general' => static function (FormBuilderInterface $builder, array $options): void {
                    $builder
                        ->add('age', NumberType::class, [
                            'label' => 'Age',
                            'constraints' => [
                                new NotBlank(),
                            ],
                        ]);

                    if ($options['customFlag'] === 'yes') {
                        $builder->add('name', TextType::class, [
                            'label' => 'Name',
                            'constraints' => [
                                new NotBlank(),
                            ],
                        ]);
                    } else {
                        $builder->add('notname', TextType::class, [
                            'label' => 'Not Name',
                            'constraints' => [
                                new NotBlank(),
                            ],
                        ]);
                    }
                },
                'contact' => static function (FormBuilderInterface $builder): void {
                    $builder
                        ->add('email', TextType::class, [
                            'label' => 'E-Mail',
                            'constraints' => [
                                new NotBlank(),
                            ],
                        ])
                        ->add('newsletter', CheckboxType::class, [
                            'label' => 'Newsletter',
                            'constraints' => [
                                new IsTrue(),
                            ],
                        ]);
                },
                'author' => AuthorType::class,
            ],
        ]);
    }
}

In the controller (this is only one possible usage of the form in order to persist the current step and the data):

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Form\MyFancyMultiStepType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;


final class HelloFormController extends AbstractController
{
    #[Route(path: '/hello-form', name: 'hello_form', methods: ['GET', 'POST'])]
    public function index(Request $request): Response
    {
        $session = $request->getSession();
        $currentStep = $session->get('current_step', 'general') ?? 'general';

        $form = $this->createForm(MyFancyMultiStepType::class, $session->get($currentStep, []), [
            'current_step' => $currentStep,
        ]);

        $session->set('current_step', $form->getConfig()->getOption('current_step'));
        $form->handleRequest($request);

        if ($form->isSubmitted()) {
            if ($form->has('back') && $form->get('back')->isClicked()) {
                $session->set('current_step', $form->getConfig()->getOption('previous_step'));

                return $this->redirectToRoute('hello_form');
            }

            if ($form->get('submit')->isClicked() && $form->isValid()) {
                $session->set($currentStep, $form->getData());
                $session->set('current_step', $form->getConfig()->getOption('next_step'));

                if (null !== $form->getConfig()->getOption('next_step')) {
                    return $this->redirectToRoute('hello_form');
                }

                $data = [];

                foreach (\array_keys($form->getConfig()->getOption('steps')) as $name) {
                    $data[$name] = $session->get($name, []);
                    $session->remove($name);
                }

                dump($data);

                return $this->redirectToRoute('hello_form');
            }
        }

        return $this->render('hello_form/template.html.twig', [
            'form' => $form->createView(),
        ]);
    }
}

And Rendering:

<div class="max-w-6xl mx-auto">
    <div class="my-12">
        {% for step in form.vars.steps %}
            <span class="{{ html_classes({'font-bold': step == form.vars.current_step }) }}">{{ step }}</span> {% if not loop.last %} - {% endif %}
        {% endfor %}
    </div>

    {{ form_start(form) }}

    {{ form_end(form) }}


    <div class="mt-12">
        {{ form.vars.current_step_number }} / {{ form.vars.total_steps_count }}
    </div>
</div>

Look and feel!

CleanShot 2025-01-19 at 14 44 00

@silasjoisten silasjoisten changed the title Enhancement: Adds MultiStepType [Form] Enhancement: Adds MultiStepType Jan 18, 2025
@silasjoisten silasjoisten changed the title [Form] Enhancement: Adds MultiStepType [Form] Add MultiStepType Jan 18, 2025
Copy link
Member

@yceruto yceruto left a comment

Choose a reason for hiding this comment

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

Interesting idea!

I wonder if we could create a default theme for this form type that would (at least):

  • render the current step form (this is already the case but might require some tweaks for the next point below)
  • render the back/next "submit" buttons based on the "step number" (which is currently missing but can be calculated easily using the provided options)

While data storage and form handling are beyond the scope of this type, I believe having that default theme would be great.

@carsonbot carsonbot changed the title [Form] Add MultiStepType Add MultiStepType Jan 18, 2025
@silasjoisten
Copy link
Contributor Author

silasjoisten commented Jan 18, 2025

Hey thanks for the review!

render the back/next "submit" buttons based on the "step number" (which is currently missing but can be calculated easily using the provided options)

I tried today to find a solution which is nice but i did not succeed. If we have a next and a previous button there would be also the need of disabling the previous button when the current step number is 0 and maybe change the label of the next button when the current step number is the last step. All this requires some sort of wrapper class including a storage. (Which will be handled in UX) if you got any idea of how to achieve that i'd very happy!

I added some more helpers in view vars which makes rendering easier and also i added some options in order to allow navigate through steps. I also updated this PR description with an example of how to use the form in a controller.

@silasjoisten silasjoisten requested a review from yceruto January 19, 2025 09:38
@carsonbot carsonbot changed the title Add MultiStepType [Form] Add MultiStepType Jan 19, 2025
Copy link
Member

@yceruto yceruto left a comment

Choose a reason for hiding this comment

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

I think the Steps and Navigator elements should be designed as two distinct types (even if they are related). This would make it easier for users to extend/customize each one independently.

{
$resolver
->setRequired('steps')
->setAllowedTypes('steps', 'array')
Copy link
Member

Choose a reason for hiding this comment

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

array -> (string|callable)[] this is a fresh feature already merged in 7.3

Copy link
Member

Choose a reason for hiding this comment

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

Should all current_step / next_step etc allow callable ? That would ease integration with Stepper or Navigator or any future WizardStepPathFinder-like.. wdyt ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea but i dont know what i should do here. Maybe i dont fully understand the approach

@yceruto
Copy link
Member

yceruto commented Jan 19, 2025

After reviewing the PR description again following your latest update, it might be helpful to include an example with a DTO (+ validator constraints bound to the underlying object) as this aligns with the recommended approach for working with forms.

I like the idea and how we can configure the steps, but handling it from the controller still feels a bit complex IMO.

@silasjoisten
Copy link
Contributor Author

After reviewing the PR description again following your latest update, it might be helpful to include an example with a DTO (+ validator constraints bound to the underlying object) as this aligns with the recommended approach for working with forms.

Sure i can add that. Thats just gonna be a fee lines of code.

I like the idea and how we can configure the steps, but handling it from the controller still feels a bit complex IMO.

Well but all of that is implementation detail. Like which storage to use which steps to skip or add to your multistep type. Without an implementation of a storage its not possible to enhance the DX. Mainly we should not forget that the main reason for this PR is to have this available for a nice LiveComponent in symfony UX.

If you have any better idea of how to increase the developer experience feel free to tell. I honestly ran out of ideas because i tried a lot of different things today. And i always came to the same conclusion... storage, storage, storage 😅

@smnandre
Copy link
Member

Would this require a lot of work to implement the following (not in this PR of course)

  • serialize / encode the data from previous steps
  • sign it
  • add a hidden field / textarea

Or is this "one" of the storages we're talking about ? Will there be any interface for the "storage" in symfony/form .. or we let entirely people implementing from scratch this part ?

@PReimers
Copy link
Contributor

PReimers commented Jan 19, 2025

Will there be any interface for the "storage" in symfony/form .. or we let entirely people implementing from scratch this part ?

We "started" with a storage imterface (and a session storage) when we build it for UX.
https://github.com/symfony/ux/blob/4c6a06576d30caf1b96a0ba2a66d0f896f767edb/src/LiveComponent/src/Storage/StorageInterface.php
https://github.com/symfony/ux/blob/4c6a06576d30caf1b96a0ba2a66d0f896f767edb/src/LiveComponent/src/Storage/SessionStorage.php

But in the meantime we figured Symfony has no place for a storage interface (except if we would create something like a storage-contract. But a storage-contract would only provide one interface, implementation like a SessionStorage/FilesystemStorage/RedisStorage/... would need to go into different components (SessionStorage in Framework? FilesystemStore in Filesystem? RedisStorage in ...)

Or are we open to add "storage" into Form?

@yceruto
Copy link
Member

yceruto commented Jan 19, 2025

Without an implementation of a storage its not possible to enhance the DX

What about adding a new FormFlow utility + a new DataStorageInterface to implement that abstraction?

Mainly we should not forget that the main reason for this PR is to have this available for a nice LiveComponent in symfony UX.

I think you raise an important point here. Introducing this type in the Symfony repo would mean designing it in a way that isn't strictly tied to the UX package, as not all users may be using UX packages like we do. It's essential to ensure that it works well for everyone.

@PReimers
Copy link
Contributor

Would this require a lot of work to implement the following (not in this PR of course)

* serialize / encode the data from previous steps
* sign it
* add a hidden field / textarea

We were thinking about implementing a hidden field containing the current state, but when the page is reloaded we'll lose the current state.

In our current example implementaion we're using the session, this is persistent across reloads (until the session is killed).
Would we need to serialize/deserialize and/or sign the data in that case?

@silasjoisten
Copy link
Contributor Author

What about adding a new FormFlow utility + a new DataStorageInterface to implement that abstraction?

Yes we could do that. As we already have the Interface. How do we do the implementation? Shall i require http foundation for the session?

I think you raise an important point here. Introducing this type in the Symfony repo would mean designing it in a way that isn't strictly tied to the UX package, as not all users may be using UX packages like we do. It's essential to ensure that it works well for everyone.

I agree on that. But isn't it usable without symfony ux yet? I mean it might be a bit more code to write but it would be already usable.

Regarding the storage where should i put it? Can you maybe help me on this one?

@smnandre
Copy link
Member

I would not be shocked if a "FormStepPersisterInterface" or "Loader" or something like this was added into Form 🤷

Like ChoiceLoaderInterface maybe ?

Or simply document the events and to plug itself to store the data.

We also can consider there is only one entity saved, partially at each step, and then nothing is really needed here ?

@smnandre
Copy link
Member

In our current example implementaion we're using the session, this is persistent across reloads (until the session is killed).
Would we need to serialize/deserialize and/or sign the data in that case?

I guess not, indeed! 👍

(documentation will maybe just need a warning regarding funnel forms with login or register in the way, as session is often reset then)

@yceruto
Copy link
Member

yceruto commented Jan 19, 2025

Yes we could do that. As we already have the Interface. How do we do the implementation? Shall i require http foundation for the session?

We shouldn't require the http foundation directly, take a look at RequestHandlerInterface mechanism (and their subclasses).

Regarding the storage where should i put it? Can you maybe help me on this one?

Let me play a bit with this proposal and I'll back next week with more details about it.

@silasjoisten
Copy link
Contributor Author

We also can consider there is only one entity saved, partially at each step, and then nothing is really needed here ?

If you concider that yes then the implementation would be Userland

@yceruto
Copy link
Member

yceruto commented Jan 20, 2025

For reference https://github.com/craue/CraueFormFlowBundle. I've used this bundle in several projects and found it suitable for most advanced cases. It's worth a look and a ping to @craue who has expertise on this topic.

@stof
Copy link
Member

stof commented Jan 20, 2025

@silasjoisten I suggest updating your example code to remove the usage of OskarStark\Symfony\Http\Responder. An example using core APIs is more useful as the community will be familiar with them (and will help the documentation team)

@silasjoisten
Copy link
Contributor Author

silasjoisten commented Jan 21, 2025

For reference https://github.com/craue/CraueFormFlowBundle. I've used this bundle in several projects and found it suitable for most advanced cases. It's worth a look and a ping to @craue who has expertise on this topic.

Yea i have worked with it as well and i did not like the DX in it. It felt quite old and i mean its a common problem why shouldn't it be part of Symfony itself. Even if its just a simple form flow without skipping things.

For some cases yes you need a more complex form flow. but sometimes you want to have it in order to have a nice User experience in you Application.

@PReimers
Copy link
Contributor

PReimers commented Feb 8, 2025

What is the current state of this PR?
Do we need to do something, or are we waiting for something?

@yceruto
Copy link
Member

yceruto commented Feb 8, 2025

I tested the proposal as it is, and it feels like too much responsibility for the user to handle (in the current state). I'm not referring to the form step definition, which is already simple, but to the navigation and data storage part across steps. IMO, there should be a default implementation that handles that for us, not a UX but a Symfony one, flexible enough to be used with or without UX capabilities.

This is my current expectation for this feature (https://gist.github.com/yceruto/0fe65c8669016fe48f24c4e047ce7fb1):

  • it should work with a DTO (including the current step property)

  • the step validation should follow the validation_groups approach with constraints defined in the DTO, e.g.

    $resolver->setDefault('validation_groups', function (Options $options) {
        return ['Default', $options['current_step']];
    });
  • default navigation buttons (back, next, submit) and an action-based navigation handling approach (rather than checking by the button name). For example, I might add a new "skip" button with a "next" action in one step, which skips validations.

  • a customizable data storage strategy (probably through a form option) to persist the data between steps. The session strategy should be the default.

my two cents :) still looking forward to this feature 💘

@silasjoisten
Copy link
Contributor Author

Nicee gist you made there! I focus ghis weekend on this Pr. And try to make it as you expect :) i like your idea very much!

if (\is_callable($currentStep)) {
$currentStep($builder, $options);
} elseif (\is_string($currentStep)) {
$builder->add($options['current_step'], $currentStep);
Copy link
Member

Choose a reason for hiding this comment

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

the step type may require specific options that differ from those in the root form.

we need to consider this, as it's currently a limitation

@94noni
Copy link
Contributor

94noni commented Feb 16, 2025

Comming from the initial UX repo PR :) nice one

If something like this land on sf/form I think it needs storage included otherwise it may be « hard/prone to error » to implement/handle and may lead to devland issues no?

});

$resolver->setDefaults([
'hide_back_button_on_first_step' => false,
Copy link
Contributor

Choose a reason for hiding this comment

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

display_ with default true instead of hide_ default false?
Seems easier to read the positive way not the négative

Copy link
Contributor

@Spomky Spomky left a comment

Choose a reason for hiding this comment

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

Many thanks for this PR.
I find the idea really very interesting. This type of need is common in applications.
However I have some questions/remarks.

return false;
}

if ((!\is_string($step) || !is_subclass_of($step, AbstractType::class)) && !\is_callable($step)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

A $step object that implements FormTypeInterface should be allowed as well

Also, I have the feeling that parenthesis should be added here (mixing || and &&).

$builder->add($options['current_step'], $currentStep);
}

$builder->add('back', SubmitType::class, [
Copy link
Contributor

@Spomky Spomky Mar 4, 2025

Choose a reason for hiding this comment

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

I'm not a fan of buttons within form type objects. I prefer their integration into the template.
There should be a way for the template to know when each button has to be displayed or not e.g. with the help of the isFirstStep or isLastStep methods.
Also, this will remove the need of the hide_back_button_on_first_step, button_back_options, button_next_options and button_submit_options options. WDYT?

@yceruto
Copy link
Member

yceruto commented Apr 9, 2025

Hi there! I'll get back to this topic next week with a complete alternative proposal that addresses the main implementation concerns (this one: #59548 (comment) and others mentioned).

I can't wait to share it with all of you!

@yceruto
Copy link
Member

yceruto commented Apr 13, 2025

Here we go #60212 !

@silasjoisten
Copy link
Contributor Author

I will close this one thank you @yceruto

@silasjoisten silasjoisten deleted the feature/multi-step-type branch April 14, 2025 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

10 participants
Morty Proxy This is a proxified and sanitized view of the page, visit original site.