Skip to content
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

[LiveComponent] Adds MultiStep Form Feature #2433

Draft
wants to merge 12 commits into
base: 2.x
Choose a base branch
from

Conversation

silasjoisten
Copy link

@silasjoisten silasjoisten commented Dec 7, 2024

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

This pull request introduces a new feature to Symfony UX LiveComponent that enables developers to easily create multistep forms in their applications. This functionality simplifies the implementation of complex, multi-step workflows while maintaining a clean and structured developer experience.

Key Features

  1. MultiStep Form Type (MultiStepType): A custom form type that handles step-specific configuration, such as defining the steps and their corresponding fields.
  2. ComponentWithMultiStepFormTrait: A reusable trait to manage the form flow, including:
    • Navigation between steps (next, previous, submit).
    • Validation and data persistence for each step.
    • Easy integration with storage mechanisms.
    • Storage Interface (StorageInterface): Provides a standardized way to persist form state across steps, enabling seamless navigation and data retrieval.

Example Usage

  1. Create the LiveComponent
<?php

declare(strict_types=1);

namespace App\Twig\Component;

use Symfony\UX\LiveComponent\Storage\StorageInterface;
use Symfony\UX\LiveComponent\ComponentWithMultiStepFormTrait;
use App\Form\Test\MyFancyWizardType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;

#[AsLiveComponent(
    name: 'SuperFancyWizard',
    template: 'components/super_fancy_wizard.html.twig',
)]
final class WizardComponent
{
    use ComponentWithMultiStepFormTrait;

    public function __construct(
        private StorageInterface $storage,
        private FormFactoryInterface $formFactory,
    ) {
    }

    public function onSubmit(): void
    {
        $data = $this->getAllData();

        // Do what ever you want with the data.

        $this->resetForm();
    }

    protected static function formClass(): string
    {
        return MyFancyWizardType::class;
    }

    protected function getFormFactory(): FormFactoryInterface
    {
        return $this->formFactory;
    }

    protected function getStorage(): StorageInterface
    {
        return $this->storage;
    }
}
  1. Create the FormType using getParent to Create a custom MultiStep form type:
<?php

declare(strict_types=1);

namespace App\Form;

use Symfony\UX\LiveComponent\Form\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;

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): void {
                    $builder
                        ->add('age', NumberType::class, [
                            'label' => 'Age',
                            'constraints' => [
                                new NotBlank(),
                            ],
                        ])
                        ->add('name', TextType::class, [
                            'label' => '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(),
                            ],
                        ]);
                },
                'bar' => static function (FormBuilderInterface $builder): void {
                    $builder
                        ->add('address', TextType::class, [
                            'label' => 'Address',
                            'constraints' => [
                                new NotBlank(),
                            ],
                        ])
                        ->add('city', TextType::class, [
                            'label' => 'City',
                            'constraints' => [
                                new NotBlank(),
                            ],
                        ]);
                },
            ],
        ]);
    }
}
<div {{ attributes }}>

    <div class="max-w-4xl mx-auto">
        {% for stepName in this.stepNames %}
            <div class="inline-flex space-x-4">
                <span class="{% if stepName == this.currentStepName %}text-red-500{% else %}text-gray-400{% endif %}">{{ stepName }}</span>
                {% if not loop.last %}
                    /
                {% endif %}
            </div>
        {% endfor %}

        {{ form(form) }}
    </div>

    <div class="max-w-4xl mx-auto my-12">
        <button
            {{ live_action('previous') }}
            type="button"
            class="btn-secondary"
            {% if this.isFirst() %}disabled="disabled"{% endif %}
        >
            Previous
        </button>

        {% if not this.isLast() %}
            <button
                {{ live_action('next') }}
                type="button"
                class="btn-primary"
            >
                Next
            </button>
        {% else %}
            <button
                {{ live_action('submit') }}
                type="button"
                class="btn-primary"
            >
                Finish
            </button>
        {% endif %}
    </div>
</div>
  1. Create a template and benefit from awesome methods to control the MultiStep form

Acknowledgments

This feature was collaboratively developed during the #SymfonyHackday following SymfonyCon Vienna 2024. Special thanks to the contributors for their outstanding work:

Your efforts and dedication have made this feature possible!

Still on my todo list:

  • Write tests
  • Update docs
  • Update CHANGELOG.md

Look and feel

CleanShot 2024-12-07 at 22 28 43

@carsonbot carsonbot added Feature New Feature Status: Needs Review Needs to be reviewed labels Dec 7, 2024
@silasjoisten silasjoisten marked this pull request as draft December 7, 2024 21:23
@seb-jean
Copy link
Contributor

seb-jean commented Dec 7, 2024

Hello, thank you very much for your contribution: the idea is very interesting.
I don't know if it's already done but is there a possibility to stay on the page of the current step when you refresh the page or does it go back to step 1?

@silasjoisten
Copy link
Author

Hello, thank you very much for your contribution: the idea is very interesting.

I don't know if it's already done but is there a possibility to stay on the page of the current step when you refresh the page or does it go back to step 1?

Hello, there is a Storage used which allows you by default to stay on the same step on page reload. So there is the persistence level already included

@seb-jean
Copy link
Contributor

seb-jean commented Dec 7, 2024

Nice!

Comment on lines 278 to 281
return u(static::class)
->afterLast('\\')
->snake()
->toString();
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
return u(static::class)
->afterLast('\\')
->snake()
->toString();
return u(static::class)->afterLast('\\')->snake()->toString();

Copy link
Contributor

Choose a reason for hiding this comment

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

And is symfony/string still a requirement ?

Copy link
Author

Choose a reason for hiding this comment

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

yes i required it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Is this the only place where it is needed? In this case it is preferred to not use a dep but use plain vanilla PHP for it

@silasjoisten silasjoisten changed the title Adds MultiStep Form Feature to Symfony UX LiveComponent [LiveComponent] Adds MultiStep Form Feature Dec 8, 2024
@WebMamba
Copy link
Contributor

WebMamba commented Dec 8, 2024

Hey @silasjoisten ! Thanks for this nice contribution ! You bring something that can be truly useful for a lot of folks! I just don't thinks that a trait in the LiveComponent component is the right place... I think we bring to much that are not LiveComponent related: StorageInterface, MultiStepType, and so on. I am also scared also that doing this in to a Trait can limit us in to the implementation and on the DX. I think we can go way further on this topics and bring features that can be also front end related (step transition).
So I am not sure where is the right place to put this work, should it be a new component ? A new documentation ? A Cookbook ?
Tell me what do you thinks about that cheers!
Thanks again 😁

@silasjoisten
Copy link
Author

Hey @WebMamba,

Thanks for your detailed feedback and for taking the time to review this! 😊

I firmly believe that this feature fits well within the Symfony UX LiveComponents component. Here’s why:

  1. Alignment with Existing Architecture:
    The multistep form concept is built on the same principles as LiveComponents: reactive, state-driven interfaces that rely on server-side logic to manage component behavior. The StorageInterface and MultiStepType were specifically designed to integrate seamlessly into the LiveComponent ecosystem. Much like ComponentWithFormTrait, which manages forms within LiveComponents, this trait extends that functionality to handle multistep forms without introducing unnecessary complexity.

  2. CollectionType as an Example:
    Consider how Symfony provides the CollectionType as part of its Form component. It’s a focused feature, but it belongs there because it enhances the developer experience for managing collections within forms. Similarly, multistep forms naturally fit within LiveComponents because they extend its reactive capabilities.

  3. DX and Limitations:
    You mentioned that this approach might limit DX or implementation options. Could you elaborate on specific scenarios where you think this might be restrictive? For instance, LiveComponents already allows for tight integration of server-side logic, storage, and frontend rendering. How would moving this feature to another component, cookbook, or package better serve the DX? From my perspective, centralizing this within LiveComponents provides developers with a familiar and consistent experience, reducing the need to integrate external solutions or reinvent the wheel.

  4. Future Enhancements:
    While this implementation focuses on server-side management, it lays a strong foundation for frontend-related features like step transitions. Since LiveComponents already facilitates reactive UI updates, extending this feature to include frontend transitions feels like a natural progression within this component.

I’d love to hear your thoughts on these points and where you see potential challenges or conflicts. If you have specific ideas for improving or repositioning the feature, I’m happy to collaborate further.

Cheers,
Silas

@smnandre
Copy link
Member

Hi @silasjoisten! So happy to see you there ❤️

First, thank you very very much for this PR! This feature will be warmly welcomed by many users.

Regarding the "where", it is now pretty clear, in my mind, that we will end up with a dedicated "LiveForm" or "UxForm" component/bundle.. probably sooner than later. But right now, we have... LiveComponent :)

Also, I do agree with you regarding the similarity with CollectionType + the DX aspects.

As i see it, we can introduce it in "experimental" anywhere we want, and we'll see until that where it best fits in UX 3.

Next comments in the code :)

I need to sleep on the "StorageInterface" thing because i'm having mixed feeling about introducing a generic tool like this for now, while we're moving things around here.

@@ -51,6 +51,7 @@
"symfony/serializer": "^5.4|^6.0|^7.0",
"symfony/twig-bundle": "^5.4|^6.0|^7.0",
"symfony/validator": "^5.4|^6.0|^7.0",
"symfony/string": "^5.4|^6.0|^7.0",
Copy link
Member

Choose a reason for hiding this comment

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

Can we remove it ( if only used for the string/camel stuff ?)

->setDefault('current_step_name', static function (Options $options): string {
return array_key_first($options['steps']);
})
->setRequired('steps');
Copy link
Member

Choose a reason for hiding this comment

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

Will require more checks

Copy link
Author

Choose a reason for hiding this comment

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

What checks you got in mind?

public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setDefault('current_step_name', static function (Options $options): string {
Copy link
Member

Choose a reason for hiding this comment

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

Let's discuss this more globally, but i think "current_step" or even "step" could be enough, and improve readability.

Maybe just me, but i feel the implementation choice (e.g., passing the steps as a map) should not impose constraints on the overall naming convention.

wdyt ?

{
$view->vars['current_step_name'] = $options['current_step_name'];
$view->vars['steps_names'] = array_keys($options['steps']);
}
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
}
}
public function getBlockPrefix(): string
{
return 'multistep';
}


public function buildForm(FormBuilderInterface $builder, array $options): void
{
$options['steps'][$options['current_step_name']]($builder);
Copy link
Member

Choose a reason for hiding this comment

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

This should be checked first i guess ? 😅

Copy link
Author

Choose a reason for hiding this comment

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

I dont think so, because configure options is already ensuring that. (see the test)

Copy link
Member

Choose a reason for hiding this comment

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

I may miss-read this, but to me at this point there is no certitude that $options['steps'][$options['current_step_name']] is a callable that accept FormBuilderInterface argument ?

Comment on lines +46 to +47
$view->vars['current_step_name'] = $options['current_step_name'];
$view->vars['steps_names'] = array_keys($options['steps']);
Copy link
Member

Choose a reason for hiding this comment

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

What about "step" and "steps" (or "current_step" and "steps" ?

To illustrate my point here: if you look at ChoiceType, it does not use "prefered_choices**_values**" or "prefered_choices**_names**"

Copy link
Author

Choose a reason for hiding this comment

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

sure we can rename this.

* @author Patrick Reimers <[email protected]>
* @author Jules Pietri <[email protected]>
*/
final class MultiStepType extends AbstractType
Copy link
Member

Choose a reason for hiding this comment

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

I think this is where we should describe/document what is a "step".

Comment on lines +21 to +27
* This class provides a session-based storage solution for managing data
* persistence in Symfony UX LiveComponent. It leverages the Symfony
* `RequestStack` to access the session and perform operations such as
* storing, retrieving, and removing data.
*
* Common use cases include persisting component state, such as form data
* or multistep workflow progress, across user interactions.
Copy link
Member

Choose a reason for hiding this comment

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

Common use cases include persisting component state, such as form data, or multistep workflow progress, across user interactions.

I'm having doubt if we should push session usage like this, as in the same time we push for stateless components, removed csrf, etc..

And i'm very hesistant to add such feature in another PR :|

I don't think these two things should be handled as one...

Will sleep on it... but already can see several alternatives:

  • having an internal/experimental storage (just pour the multi-step form)
  • using props
  • using existing adapters (doctrine, cache)

Copy link
Author

Choose a reason for hiding this comment

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

If it is stateless. okay we could introduce a NullStorage. But in order to be able to reload the page and having a persistent state is quite cool in my opinion.

Comment on lines +227 to +230
/**
* Abstract method to be implemented by the component for custom submission logic.
*/
abstract public function onSubmit();
Copy link
Member

Choose a reason for hiding this comment

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

Not sure what this mean, but onSubmit feels uncommon name.. :/

Copy link
Author

Choose a reason for hiding this comment

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

Maybe doSubmit ?

return;
}

$this->getStorage()->persist(\sprintf('%s_form_values_%s', self::prefix(), $this->currentStepName), $this->form->getData());
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
$this->getStorage()->persist(\sprintf('%s_form_values_%s', self::prefix(), $this->currentStepName), $this->form->getData());
$this->getStorage()->persist(\sprintf('%s_form_values_%s', self::prefix(), $this->currentStepName), $this->form->getData());

This feels very implementation specific to me... more on that later :)

#[ExposeInTemplate]
public function isFirst(): bool
{
return $this->currentStepName === $this->stepNames[array_key_first($this->stepNames)];
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
return $this->currentStepName === $this->stepNames[array_key_first($this->stepNames)];
return $this->currentStep === reset($this->steps);

wdyt ?

Copy link
Author

Choose a reason for hiding this comment

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

reset can return false which makes it not typesafe enough for me.

Copy link
Member

Choose a reason for hiding this comment

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

Then currentStep === .. would return false no ?

#[ExposeInTemplate]
public function isLast(): bool
{
return $this->currentStepName === $this->stepNames[array_key_last($this->stepNames)];
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
return $this->currentStepName === $this->stepNames[array_key_last($this->stepNames)];
return $this->currentStep === end($this->steps);

Copy link
Author

Choose a reason for hiding this comment

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

i would like to use the new php functions array_key_first and array_key_last.

Copy link
Member

Choose a reason for hiding this comment

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

haha the "new" :)

The "problem" (a detail, to be honest) here is that you first look for a key, then get the value indexed by that key, then compare currentStepName is identical.

But we can update this in time if necessary :)

Comment on lines +151 to +170
$found = false;
$previous = null;

foreach (array_reverse($this->stepNames) as $stepName) {
if ($this->currentStepName === $stepName) {
$found = true;

continue;
}

if ($found) {
$previous = $stepName;

break;
}
}

if (null === $previous) {
throw new \RuntimeException('No previous forms available.');
}
Copy link
Member

Choose a reason for hiding this comment

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

$current = array_search($currentStep, $steps, true);

if (!$current) {
    throw new \RuntimeException('No previous steps available.');
}

$previous = $steps[$current - 1];

(or similar)

Copy link
Author

Choose a reason for hiding this comment

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

with you example the exception is thrown if no current step is available.

Comment on lines +173 to +185
$this->getStorage()->persist(\sprintf('%s_current_step_name', self::prefix()), $this->currentStepName);

$this->form = $this->instantiateForm();
$this->formView = null;

$formData = $this->getStorage()->get(\sprintf(
'%s_form_values_%s',
self::prefix(),
$this->currentStepName,
));

$this->formValues = $formData;
$this->form->setData($formData);
Copy link
Member

Choose a reason for hiding this comment

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

Duplicate with "next" method... should this be the main "getCurrentForm" or "getStepForm" method or something like that ?

Comment on lines +97 to +101
$this->submitForm();

if ($this->hasValidationErrors()) {
return;
}
Copy link
Member

Choose a reason for hiding this comment

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

If !form->isValid, then a UnprocessableEntityHttpException is triggered in the componentwithformtrait, no ? So as i see it, the hasValidationErrors() check is redondant (i may have missed something)

Suggested change
$this->submitForm();
if ($this->hasValidationErrors()) {
return;
}
$this->submitForm();

Copy link
Author

Choose a reason for hiding this comment

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

not sure to be honest. i can test it.

Comment on lines +43 to +44
#[LiveProp]
public ?string $currentStepName = null;
Copy link
Member

Choose a reason for hiding this comment

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

Should we make this non nullable? I don't see when a multi-step could have "no current step"

Copy link
Author

Choose a reason for hiding this comment

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

We are in a trait and we dont have a constructor or something here. which means it depends on the #[PostMount] hook. if thats not executed it is null by default. i think thats something by design we should not change.


$this->stepNames = $this->formView->vars['steps_names'];

// Do not move this. The order is important.
Copy link
Member

Choose a reason for hiding this comment

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

Why? 😅

Copy link
Author

@silasjoisten silasjoisten Dec 11, 2024

Choose a reason for hiding this comment

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

good question :D i cann add a better comment

Copy link

@WedgeSama WedgeSama left a comment

Choose a reason for hiding this comment

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

Raw thought:

IMO, this feature can have its place in Form component, it will be useful for people/project that not using ux yet and will ease transitioning to ux.

And that way StorageInterface make sense in the Form component.


public function buildForm(FormBuilderInterface $builder, array $options): void
{
$options['steps'][$options['current_step_name']]($builder);

Choose a reason for hiding this comment

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

Allowing to use FormType as step can be good too. What do you think?

Maybe with something like that (haven't tested it):

Suggested change
$options['steps'][$options['current_step_name']]($builder);
$step = $options['steps'][$options['current_step_name']];
if (is_callable()) {
$step($builder);
} elseif (is_string($step) && is_a($step, FormTypeInterface::class)) {
$builder->add('step', $step, [
'inherit_data' => true,
]);
}

Copy link
Author

Choose a reason for hiding this comment

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

yes i was thinking about the same. so callback or class string of FormTypeInterface.


public function buildForm(FormBuilderInterface $builder, array $options): void
{
$options['steps'][$options['current_step_name']]($builder);

Choose a reason for hiding this comment

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

Suggested change
$options['steps'][$options['current_step_name']]($builder);
$options['steps'][$options['current_step_name']]($builder, $options);

IMO, passing $options to keep consistent with buildForm method can be good.

Copy link
Author

Choose a reason for hiding this comment

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

sure we can do that but my question is. Why would it make sense to pass the options of the "parent" form StepType to the children? you got an example or use case where it might be useful?

Choose a reason for hiding this comment

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

It is more to be consistent with how work the buildForm method, where you have access to options.

In your PR, you cannot set your form options yet, so there is no direct example. instantiateForm method pass an empty $options (except the current_step_name) to FormFactory::create.

You may want to create step form that is configurable, either from:

  • your live component (not yet possible)
  • using FormTypeExtension from Form component

Is it clearer that way?

@smnandre
Copy link
Member

My overall reaction here would be : i kinda feel 80% of this PR should be in ... the symfony/form component.

Both the "steps" logic and the "storage" logic are not related with Symfony UX and could be used without it.
... and would be very usefull seen the overall demand for it (in live component but in general too)

I'd even go to say LiveComponent could only provide a storage (in the browser DOM, via a LiveProp) for this feature.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Feature New Feature Status: Needs Review Needs to be reviewed
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants