Skip to content

[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
5 changes: 5 additions & 0 deletions src/Symfony/Component/Form/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
CHANGELOG
=========

7.3
---

* Add `MultiStepType` to create multistep forms

7.2
---

Expand Down
174 changes: 174 additions & 0 deletions src/Symfony/Component/Form/Extension/Core/Type/MultiStepType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php

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

namespace Symfony\Component\Form\Extension\Core\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\Exception\InvalidOptionsException;
use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
* @author Silas Joisten <silasjoisten@proton.me>
* @author Patrick Reimers <preimers@pm.me>
* @author Jules Pietri <jules@heahprod.com>
*/
final class MultiStepType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$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

->setAllowedValues('steps', static function (array $steps): bool {
foreach ($steps as $key => $step) {
if (!\is_string($key)) {
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 &&).

return false;
}
}

return true;
})
->setRequired('current_step')
->setAllowedTypes('current_step', 'string')
->setNormalizer('current_step', static function (Options $options, string $value): string {
if (!\array_key_exists($value, $options['steps'])) {
throw new InvalidOptionsException(\sprintf('The current step "%s" does not exist.', $value));
}

return $value;
})
->setDefault('current_step', static function (Options $options): string {
/** @var string $firstStep */
$firstStep = array_key_first($options['steps']);

return $firstStep;
})
->setRequired('next_step')
->setAllowedTypes('next_step', ['string', 'null'])
->setDefault('next_step', function (Options $options): ?string {
return array_keys($options['steps'])[$this->currentStepIndex($options['current_step'], $options['steps']) + 1] ?? null;
})
->setNormalizer('next_step', static function (Options $options, ?string $value): ?string {
if (null === $value) {
return null;
}

if (!\array_key_exists($value, $options['steps'])) {
throw new InvalidOptionsException(\sprintf('The next step "%s" does not exist.', $value));
}

return $value;
})
->setRequired('previous_step')
->setAllowedTypes('previous_step', ['string', 'null'])
->setDefault('previous_step', function (Options $options): ?string {
return array_keys($options['steps'])[$this->currentStepIndex($options['current_step'], $options['steps']) - 1] ?? null;
})
->setNormalizer('previous_step', static function (Options $options, ?string $value): ?string {
if (null === $value) {
return null;
}

if (!\array_key_exists($value, $options['steps'])) {
throw new InvalidOptionsException(\sprintf('The previous step "%s" does not exist.', $value));
}

return $value;
});

$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

'button_back_options' => [
'label' => 'Back',
],
'button_next_options' => [
'label' => 'Next',
],
'button_submit_options' => [
'label' => 'Finish',
],
]);

$resolver->setAllowedTypes('hide_back_button_on_first_step', 'bool');
$resolver->setAllowedTypes('button_back_options', 'array');
$resolver->setAllowedTypes('button_submit_options', 'array');
}

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

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

}

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

'disabled' => $this->isFirstStep($options['current_step'], $options['steps']),
'validate' => false,
...$options['button_back_options'],
]);

if ($this->isFirstStep($options['current_step'], $options['steps']) && true === $options['hide_back_button_on_first_step']) {
$builder->remove('back');
}

$builder->add('submit', SubmitType::class, $this->isLastStep($options['current_step'], $options['steps']) ? $options['button_submit_options'] : $options['button_next_options']);
}

public function buildView(FormView $view, FormInterface $form, array $options): void
{
$view->vars['current_step'] = $options['current_step'];
$view->vars['steps'] = array_keys($options['steps']);
$view->vars['total_steps_count'] = \count($options['steps']);
$view->vars['current_step_number'] = $this->currentStepIndex($options['current_step'], $options['steps']) + 1;
$view->vars['is_first_step'] = $this->isFirstStep($options['current_step'], $options['steps']);
$view->vars['is_last_step'] = $this->isLastStep($options['current_step'], $options['steps']);
$view->vars['previous_step'] = $options['previous_step'];
$view->vars['next_step'] = $options['next_step'];
}

/**
* @param array<string, mixed> $steps
*/
private function currentStepIndex(string $currentStep, array $steps): int
{
/** @var int $currentStep */
$currentStep = array_search($currentStep, array_keys($steps), true);

return $currentStep;
}

/**
* @param array<string, mixed> $steps
*/
private function isLastStep(string $currentStep, array $steps): bool
{
return array_key_last(array_keys($steps)) === $this->currentStepIndex($currentStep, $steps);
}

/**
* @param array<string, mixed> $steps
*/
private function isFirstStep(string $currentStep, array $steps): bool
{
return 0 === $this->currentStepIndex($currentStep, $steps);
}
}
Loading
Loading