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
283 changes: 283 additions & 0 deletions src/LiveComponent/src/ComponentWithMultiStepFormTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
<?php

declare(strict_types=1);
silasjoisten marked this conversation as resolved.
Show resolved Hide resolved

/*
* 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;

/**
* @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 = [];

public function hasValidationErrors(): bool
{
return $this->form->isSubmitted() && !$this->form->isValid();
}

/**
* @internal
*
* 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,
));
silasjoisten marked this conversation as resolved.
Show resolved Hide resolved

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

if ([] === $formData) {
$this->formValues = $this->extractFormValues($this->getFormView());
} else {
$this->formValues = $formData;
}
silasjoisten marked this conversation as resolved.
Show resolved Hide resolved

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

#[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,
));

// I really don't understand why we need to do that. But what I understood is extractFormValues creates
// an array of initial values.
silasjoisten marked this conversation as resolved.
Show resolved Hide resolved
if ([] === $formData) {
$this->formValues = $this->extractFormValues($this->getFormView());
} else {
$this->formValues = $formData;
}

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

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

}

#[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 :)

}

#[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(),
);

$this->onSubmit();
}

abstract public function onSubmit();

/**
* @return array<string, mixed>
*/
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;
}

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 protected function getStorage(): StorageInterface;

/**
* @return class-string<FormInterface>
*/
abstract protected static function formClass(): string;

abstract protected function getFormFactory(): FormFactoryInterface;

/**
* @internal
*/
protected function instantiateForm(): FormInterface
{
$options = [];

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

return $this->getFormFactory()->create(
type: static::formClass(),
options: $options,
silasjoisten marked this conversation as resolved.
Show resolved Hide resolved
);
}

/**
* @internal
*/
private static function prefix(): string
{
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

}
}
49 changes: 49 additions & 0 deletions src/LiveComponent/src/Form/Type/MultiStepType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);
silasjoisten marked this conversation as resolved.
Show resolved Hide resolved

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

}
44 changes: 44 additions & 0 deletions src/LiveComponent/src/Storage/SessionStorage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);
silasjoisten marked this conversation as resolved.
Show resolved Hide resolved

/*
* 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\Storage;

use Symfony\Component\HttpFoundation\RequestStack;

/**
* @author Silas Joisten <[email protected]>
* @author Patrick Reimers <[email protected]>
* @author Jules Pietri <[email protected]>
*/
final class SessionStorage implements StorageInterface
{
public function __construct(
private readonly RequestStack $requestStack,
) {
}

public function persist(string $key, mixed $values): void
{
$this->requestStack->getSession()->set($key, $values);
}

public function remove(string $key): void
{
$this->requestStack->getSession()->remove($key);
}

public function get(string $key, mixed $default = []): mixed
{
return $this->requestStack->getSession()->get($key, $default);
}
}
Loading
Loading