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
1 change: 1 addition & 0 deletions src/LiveComponent/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?)

"zenstruck/browser": "^1.2.0",
"zenstruck/foundry": "^2.0"
},
Expand Down
312 changes: 312 additions & 0 deletions src/LiveComponent/src/ComponentWithMultiStepFormTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
<?php


/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\LiveComponent;

use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\Storage\StorageInterface;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
use Symfony\UX\TwigComponent\Attribute\PostMount;

use function Symfony\Component\String\u;

/**
* Trait for managing multi-step forms in LiveComponent.
*
* This trait simplifies the implementation of multi-step forms by handling
* step transitions, form validation, data persistence, and state management.
*
* @author Silas Joisten <[email protected]>
* @author Patrick Reimers <[email protected]>
* @author Jules Pietri <[email protected]>
*/
trait ComponentWithMultiStepFormTrait
silasjoisten marked this conversation as resolved.
Show resolved Hide resolved
{
use ComponentWithFormTrait;
use DefaultActionTrait;

#[LiveProp]
public ?string $currentStepName = null;
Comment on lines +40 to +41
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.


/**
* @var string[]
*/
#[LiveProp]
public array $stepNames = [];

/**
* Checks if the current form has validation errors.
*/
public function hasValidationErrors(): bool
{
return $this->form->isSubmitted() && !$this->form->isValid();
}

/**
* @internal
*
* Initializes the form and restores the state from storage.
*
* This method must be executed after `ComponentWithFormTrait::initializeForm()`.
*/
#[PostMount(priority: -250)]
public function initialize(): void
{
$this->currentStepName = $this->getStorage()->get(\sprintf('%s_current_step_name', self::prefix()), $this->formView->vars['current_step_name']);

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

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

$this->form->setData($formData);

$this->formValues = [] === $formData
? $this->extractFormValues($this->getFormView())
: $formData;

$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

$this->formView = null;
}

/**
* Advances to the next step in the form.
*
* Validates the current step, saves its data, and moves to the next step.
* Throws a RuntimeException if no next step is available.
*/
#[LiveAction]
public function next(): void
{
$this->submitForm();

if ($this->hasValidationErrors()) {
return;
}
Comment on lines +94 to +98
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.


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

$found = false;
$next = null;

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

continue;
}

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

break;
}
}

if (null === $next) {
throw new \RuntimeException('No next forms available.');
}

$this->currentStepName = $next;
$this->getStorage()->persist(\sprintf('%s_current_step_name', self::prefix()), $this->currentStepName);

// If we have a next step, we need to resinstantiate the form and reset the form view and values.
$this->form = $this->instantiateForm();
$this->formView = null;

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

$this->formValues = [] === $formData
? $this->extractFormValues($this->getFormView())
: $formData;

$this->form->setData($formData);
}

/**
* Moves to the previous step in the form.
*
* Retrieves the previous step's data and updates the form state.
* Throws a RuntimeException if no previous step is available.
*/
#[LiveAction]
public function previous(): void
{
$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.');
}
Comment on lines +148 to +167
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.


$this->currentStepName = $previous;
$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);
Comment on lines +170 to +182
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 ?

}

/**
* Checks if the current step is the first step.
*
* @return bool true if the current step is the first; false otherwise
*/
#[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 ?

}

/**
* Checks if the current step is the last step.
*
* @return bool true if the current step is the last; false otherwise
*/
#[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 :)

}

/**
* Submits the form and triggers the `onSubmit` callback if valid.
*/
#[LiveAction]
public function submit(): void
{
$this->submitForm();

if ($this->hasValidationErrors()) {
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 :)


$this->onSubmit();
}

/**
* Abstract method to be implemented by the component for custom submission logic.
*/
abstract public function onSubmit();
Comment on lines +224 to +227
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 ?


/**
* Retrieves all data from all steps.
*
* @return array<string, mixed> an associative array of step names and their data
*/
public function getAllData(): array
{
$data = [];

foreach ($this->stepNames as $stepName) {
$data[$stepName] = $this->getStorage()->get(\sprintf('%s_form_values_%s', self::prefix(), $stepName));
}

return $data;
}

/**
* Resets the form, clearing all stored data and returning to the first step.
*/
public function resetForm(): void
{
foreach ($this->stepNames as $stepName) {
$this->getStorage()->remove(\sprintf('%s_form_values_%s', self::prefix(), $stepName));
}

$this->getStorage()->remove(\sprintf('%s_current_step_name', self::prefix()));

$this->currentStepName = $this->stepNames[array_key_first($this->stepNames)];
$this->form = $this->instantiateForm();
$this->formView = null;
$this->formValues = $this->extractFormValues($this->getFormView());
}

/**
* Abstract method to retrieve the storage implementation.
*
* @return StorageInterface the storage instance
*/
abstract protected function getStorage(): StorageInterface;

/**
* Abstract method to specify the form class for the component.
*
* @return class-string<FormInterface> the form class name
*/
abstract protected static function formClass(): string;

/**
* Abstract method to retrieve the form factory instance.
*
* @return FormFactoryInterface the form factory
*/
abstract protected function getFormFactory(): FormFactoryInterface;

/**
* @internal
*
* Instantiates the form for the current step
*
* @return FormInterface the form instance
*/
protected function instantiateForm(): FormInterface
{
$options = [];

if (null !== $this->currentStepName) {
$options['current_step_name'] = $this->currentStepName;
}

return $this->getFormFactory()->create(static::formClass(), null, $options);
}

/**
* @internal
*
* Generates a unique prefix based on the component's class name
*
* @return string the generated prefix in snake case
*/
private static function prefix(): string
{
return u(static::class)->afterLast('\\')->snake()->toString();
}
}
48 changes: 48 additions & 0 deletions src/LiveComponent/src/Form/Type/MultiStepType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php


/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\LiveComponent\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
* @author Silas Joisten <[email protected]>
* @author Patrick Reimers <[email protected]>
* @author Jules Pietri <[email protected]>
*/
final class MultiStepType extends AbstractType
silasjoisten marked this conversation as resolved.
Show resolved Hide resolved
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".

{
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 ?

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 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 ?

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.

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?

}

public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['current_step_name'] = $options['current_step_name'];
$view->vars['steps_names'] = array_keys($options['steps']);
Comment on lines +45 to +46
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.

}
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';
}

}
Loading
Loading