diff --git a/src/Symfony/Component/Form/CHANGELOG.md b/src/Symfony/Component/Form/CHANGELOG.md index 3b1fabfd17afd..7f89fb767a9e5 100644 --- a/src/Symfony/Component/Form/CHANGELOG.md +++ b/src/Symfony/Component/Form/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.3 +--- + + * Add `MultiStepType` to create multistep forms + 7.2 --- diff --git a/src/Symfony/Component/Form/Extension/Core/Type/MultiStepType.php b/src/Symfony/Component/Form/Extension/Core/Type/MultiStepType.php new file mode 100644 index 0000000000000..26d03cd8e1ca0 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/Core/Type/MultiStepType.php @@ -0,0 +1,174 @@ + + * + * 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 + * @author Patrick Reimers + * @author Jules Pietri + */ +final class MultiStepType extends AbstractType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setRequired('steps') + ->setAllowedTypes('steps', 'array') + ->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)) { + 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, + '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); + } + + $builder->add('back', SubmitType::class, [ + '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 $steps + */ + private function currentStepIndex(string $currentStep, array $steps): int + { + /** @var int $currentStep */ + $currentStep = array_search($currentStep, array_keys($steps), true); + + return $currentStep; + } + + /** + * @param array $steps + */ + private function isLastStep(string $currentStep, array $steps): bool + { + return array_key_last(array_keys($steps)) === $this->currentStepIndex($currentStep, $steps); + } + + /** + * @param array $steps + */ + private function isFirstStep(string $currentStep, array $steps): bool + { + return 0 === $this->currentStepIndex($currentStep, $steps); + } +} diff --git a/src/Symfony/Component/Form/Tests/Extension/Core/Type/MultiStepTypeTest.php b/src/Symfony/Component/Form/Tests/Extension/Core/Type/MultiStepTypeTest.php new file mode 100644 index 0000000000000..8ed5068ca66b3 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Extension/Core/Type/MultiStepTypeTest.php @@ -0,0 +1,322 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Extension\Core\Type; + +use Symfony\Component\Form\Extension\Core\Type\MultiStepType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\Test\TypeTestCase; +use Symfony\Component\Form\Tests\Fixtures\AuthorType; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; +use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; + +/** + * @author Silas Joisten + */ +final class MultiStepTypeTest extends TypeTestCase +{ + public function testConfigureOptionsWithoutStepsThrowsException() + { + self::expectException(MissingOptionsException::class); + + $this->factory->create(MultiStepType::class); + } + + /** + * @dataProvider invalidStepValues + */ + public function testConfigureOptionsStepsMustBeArray(mixed $steps) + { + self::expectException(InvalidOptionsException::class); + + $this->factory->create(MultiStepType::class, [], ['steps' => $steps]); + } + + /** + * @return iterable>> + */ + public static function invalidStepValues(): iterable + { + yield 'Steps is string' => ['hello there']; + yield 'Steps is int' => [3]; + yield 'Steps is null' => [null]; + } + + /** + * @dataProvider invalidSteps + * + * @param array $steps + */ + public function testConfigureOptionsStepsMustBeClassStringOrCallable(array $steps) + { + self::expectException(InvalidOptionsException::class); + self::expectExceptionMessage('The option "steps" with value array is invalid.'); + + $this->factory->create(MultiStepType::class, [], ['steps' => $steps]); + } + + /** + * @return iterable>> + */ + public static function invalidSteps(): iterable + { + yield 'Steps with invalid string value' => [['step1' => static function (): void {}, 'step2' => 'hello there']]; + yield 'Steps with invalid class value' => [['step1' => static function (): void {}, 'step2' => \stdClass::class]]; + yield 'Steps with array value' => [['step1' => static function (): void {}, 'step2' => []]]; + yield 'Steps with null value' => [['step1' => null]]; + yield 'Steps with int value' => [['step1' => 4]]; + yield 'Steps as non associative array' => [[0 => static function (): void {}]]; + } + + /** + * @dataProvider invalidStepNames + */ + public function testConfigureOptionsCurrentStepMustBeString(mixed $steps) + { + self::expectException(InvalidOptionsException::class); + + $this->factory->create(MultiStepType::class, [], ['steps' => ['step1' => static function (): void {}], 'current_step' => $steps]); + } + + /** + * @return iterable>> + */ + public static function invalidStepNames(): iterable + { + yield 'Step name is int' => [3]; + yield 'Step name is bool' => [false]; + yield 'Step name is callable' => [static function (): void {}]; + } + + public function testConfigureOptionsCurrentStepMustExistInSteps() + { + self::expectException(InvalidOptionsException::class); + self::expectExceptionMessage('The current step "step2" does not exist.'); + + $this->factory->create(MultiStepType::class, [], ['steps' => ['step1' => static function (): void {}], 'current_step' => 'step2']); + } + + /** + * @dataProvider invalidStepNames + */ + public function testConfigureOptionsNextStepMustBeStringOrNull(mixed $steps) + { + self::expectException(InvalidOptionsException::class); + + $this->factory->create(MultiStepType::class, [], ['steps' => ['step1' => static function (): void {}, 'step2' => static function (): void {}], 'next_step' => $steps]); + } + + public function testNextStepDefault() + { + $form = $this->factory->create(MultiStepType::class, [], [ + 'current_step' => 'step1', + 'steps' => [ + 'step1' => static function (): void {}, + 'step2' => static function (): void {}, + 'step3' => static function (): void {}, + ], + ]); + + self::assertSame('step2', $form->getConfig()->getOption('next_step')); + } + + public function testNextStepDefaultNullWhenNoNextStepsAvailable() + { + $form = $this->factory->create(MultiStepType::class, [], [ + 'current_step' => 'step3', + 'steps' => [ + 'step1' => static function (): void {}, + 'step2' => static function (): void {}, + 'step3' => static function (): void {}, + ], + ]); + + self::assertNull($form->getConfig()->getOption('next_step')); + } + + public function testNextStepMustBeNullOrInSteps() + { + self::expectException(InvalidOptionsException::class); + + $this->factory->create(MultiStepType::class, [], [ + 'current_step' => 'step1', + 'next_step' => 'step20', + 'steps' => [ + 'step1' => static function (): void {}, + 'step2' => static function (): void {}, + 'step3' => static function (): void {}, + ], + ]); + } + + /** + * @dataProvider invalidStepNames + */ + public function testConfigureOptionsPreviousStepMustBeStringOrNull(mixed $steps) + { + self::expectException(InvalidOptionsException::class); + + $this->factory->create(MultiStepType::class, [], ['steps' => ['step1' => static function (): void {}, 'step2' => static function (): void {}], 'previous_step' => $steps]); + } + + public function testPreviousStepDefault() + { + $form = $this->factory->create(MultiStepType::class, [], [ + 'current_step' => 'step2', + 'steps' => [ + 'step1' => static function (): void {}, + 'step2' => static function (): void {}, + 'step3' => static function (): void {}, + ], + ]); + + self::assertSame('step1', $form->getConfig()->getOption('previous_step')); + } + + public function testPreviousStepDefaultNullWhenNoNextStepsAvailable() + { + $form = $this->factory->create(MultiStepType::class, [], [ + 'current_step' => 'step1', + 'steps' => [ + 'step1' => static function (): void {}, + 'step2' => static function (): void {}, + 'step3' => static function (): void {}, + ], + ]); + + self::assertNull($form->getConfig()->getOption('previous_step')); + } + + public function testPreviousStepMustBeNullOrInSteps() + { + self::expectException(InvalidOptionsException::class); + + $this->factory->create(MultiStepType::class, [], [ + 'current_step' => 'step1', + 'previous_step' => 'step20', + 'steps' => [ + 'step1' => static function (): void {}, + 'step2' => static function (): void {}, + 'step3' => static function (): void {}, + ], + ]); + } + + public function testConfigureOptionsSetsDefaultValueForCurrentStepName() + { + $form = $this->factory->create(MultiStepType::class, [], [ + 'steps' => [ + 'step1' => static function (): void {}, + 'step2' => static function (): void {}, + 'step3' => static function (): void {}, + ], + ]); + + self::assertSame('step1', $form->createView()->vars['current_step']); + } + + public function testBuildFormStepCanBeCallable() + { + $form = $this->factory->create(MultiStepType::class, [], [ + 'current_step' => 'contact', + 'steps' => [ + 'general' => static function (FormBuilderInterface $builder): void { + $builder + ->add('firstName', TextType::class) + ->add('lastName', TextType::class); + }, + 'contact' => static function (FormBuilderInterface $builder): void { + $builder + ->add('address', TextType::class) + ->add('city', TextType::class); + }, + ], + ]); + + self::assertArrayHasKey('address', $form->createView()->children); + self::assertArrayHasKey('city', $form->createView()->children); + } + + public function testBuildFormStepCanBeClassString() + { + $form = $this->factory->create(MultiStepType::class, [], [ + 'current_step' => 'author', + 'steps' => [ + 'general' => static function (FormBuilderInterface $builder): void { + $builder + ->add('firstName', TextType::class) + ->add('lastName', TextType::class); + }, + 'author' => AuthorType::class, + ], + ]); + + self::assertArrayHasKey('author', $form->createView()->children); + } + + public function testBuildView() + { + $form = $this->factory->create(MultiStepType::class, [], [ + 'current_step' => 'contact', + 'steps' => [ + 'contact' => static function (): void {}, + 'general' => static function (): void {}, + 'newsletter' => static function (): void {}, + ], + ]); + + self::assertSame('contact', $form->createView()->vars['current_step']); + self::assertSame(['contact', 'general', 'newsletter'], $form->createView()->vars['steps']); + self::assertSame(3, $form->createView()->vars['total_steps_count']); + self::assertSame(1, $form->createView()->vars['current_step_number']); + self::assertTrue($form->createView()->vars['is_first_step']); + self::assertFalse($form->createView()->vars['is_last_step']); + } + + public function testBuildViewIsLastStep() + { + $form = $this->factory->create(MultiStepType::class, [], [ + 'current_step' => 'newsletter', + 'steps' => [ + 'contact' => static function (): void {}, + 'general' => static function (): void {}, + 'newsletter' => static function (): void {}, + ], + ]); + + self::assertSame('newsletter', $form->createView()->vars['current_step']); + self::assertSame(['contact', 'general', 'newsletter'], $form->createView()->vars['steps']); + self::assertSame(3, $form->createView()->vars['total_steps_count']); + self::assertSame(3, $form->createView()->vars['current_step_number']); + self::assertFalse($form->createView()->vars['is_first_step']); + self::assertTrue($form->createView()->vars['is_last_step']); + } + + public function testBuildViewStepIsNotLastAndNotFirst() + { + $form = $this->factory->create(MultiStepType::class, [], [ + 'current_step' => 'general', + 'steps' => [ + 'contact' => static function (): void {}, + 'general' => static function (): void {}, + 'newsletter' => static function (): void {}, + ], + ]); + + self::assertSame('general', $form->createView()->vars['current_step']); + self::assertSame(['contact', 'general', 'newsletter'], $form->createView()->vars['steps']); + self::assertSame(3, $form->createView()->vars['total_steps_count']); + self::assertSame(2, $form->createView()->vars['current_step_number']); + self::assertFalse($form->createView()->vars['is_first_step']); + self::assertFalse($form->createView()->vars['is_last_step']); + } +}