8000 [Form] Add `MultiStepType` by silasjoisten · Pull Request #59548 · symfony/symfony · GitHub
[go: up one dir, main page]

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
Prev Previous commit
Next Next commit
Fix
  • Loading branch information
silasjoisten committed Jan 19, 2025
commit a23b651ff1c041b41ef6fe21dd924f995893636e
51 changes: 35 additions & 16 deletions src/Symfony/Component/Form/Extension/Core/Type/MultiStepType.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
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;

Expand All @@ -29,15 +30,31 @@
{
$resolver
->setRequired('steps')
->setDefault('current_step', static function (Options $options): string {
/** @var array<string, mixed> $steps */
$steps = $options['steps'];
$firstStep = array_key_first($steps);
if (!\is_string($firstStep)) {
throw new \InvalidArgumentException('The option "steps" must be an associative array.');
->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)) {
return false;
}
}

return $firstStep;
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 {

Check failure on line 56 in src/Symfony/Component/Form/Extension/Core/Type/MultiStepType.php

View workflow job for this annotation

GitHub Actions / Psalm

InvalidNullableReturnType

src/Symfony/Component/Form/Extension/Core/Type/MultiStepType.php:56:78: InvalidNullableReturnType: The declared return type 'string' for /home/runner/work/symfony/symfony/src/symfony/component/form/extension/core/type/multisteptype.php:56:1982:-:closure is not nullable, but 'key-of<TArray>|null' contains null (see https://psalm.dev/144)

Check failure on line 56 in src/Symfony/Component/Form/Extension/Core/Type/MultiStepType.php

View workflow job for this annotation

GitHub Actions / Psalm

InvalidNullableReturnType

src/Symfony/Component/Form/Extension/Core/Type/MultiStepType.php:56:78: InvalidNullableReturnType: The declared return type 'string' for /home/runner/work/symfony/symfony/src/symfony/component/form/extension/core/type/multisteptype.php:56:1982:-:closure is not nullable, but 'key-of<TArray>|null' contains null (see https://psalm.dev/144)
return \array_key_first($options['steps']);

Check failure on line 57 in src/Symfony/Component/Form/Extension/Core/Type/MultiStepType.php

View workflow job for this annotation

GitHub Actions / Psalm

NullableReturnStatement

src/Symfony/Component/Form/Extension/Core/Type/MultiStepType.php:57:24: NullableReturnStatement: The declared return type 'string' for /home/runner/work/symfony/symfony/src/symfony/component/form/extension/core/type/multisteptype.php:56:1982:-:closure is not nullable, but the function returns '(key-of<array<TKey:fn-array_key_first as array-key, mixed>>)|null' (see https://psalm.dev/139)

Check failure on line 57 in src/Symfony/Component/Form/Extension/Core/Type/MultiStepType.php

View workflow job for this annotation

GitHub Actions / Psalm

NullableReturnStatement

src/Symfony/Component/Form/Extension/Core/Type/MultiStepType.php:57:24: NullableReturnStatement: The declared return type 'string' for /home/runner/work/symfony/symfony/src/symfony/component/form/extension/core/type/multisteptype.php:56:1982:-:closure is not nullable, but the function returns '(key-of<array<TKey:fn-array_key_first as array-key, mixed>>)|null' (see https://psalm.dev/139)
});
}

Expand All @@ -48,21 +65,23 @@
if (\is_callable($currentStep)) {
$currentStep($builder, $options);
} elseif (\is_string($currentStep)) {
if (!class_exists($currentStep)) {
throw new \InvalidArgumentException(\sprintf('The form class "%s" does not exist.', $currentStep));
}

if (!is_subclass_of($currentStep, AbstractType::class)) {
throw new \InvalidArgumentException(\sprintf('"%s" is not a form type.', $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

}
}

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['steps'] = \array_keys($options['steps']);
$view->vars['total_steps_count'] = \count($options['steps']);

/** @var int $currentStepIndex */
$currentStepIndex = \array_search($options['current_step'], \array_keys($options['steps']), true);
$view->vars['current_step_number'] = $currentStepIndex + 1;
$view->vars['is_first_step'] = $currentStepIndex === 0;

/** @var int $lastStepIndex */
$lastStepIndex = \array_key_last(\array_keys($options['steps']));
$view->vars['is_last_step'] = $lastStepIndex === $currentStepIndex;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
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;

/**
Expand All @@ -30,35 +31,98 @@ public function testConfigureOptionsWithoutStepsThrowsException()
$this->factory->create(MultiStepType::class);
}

public function testConfigureOptionsWithStepsSetsDefaultForCurrentStepName()
/**
* @dataProvider invalidStepValues
*/
public function testConfigureOptionsStepsMustBeArray(mixed $steps)
{
$form = $this->factory->create(MultiStepType::class, [], [
'steps' => [
'general' => static function (): void {},
'contact' => static function (): void {},
'newsletter' => static function (): void {},
],
]);
self::expectException(InvalidOptionsException::class);

self::assertSame('general', $form->createView()->vars['current_step']);
$this->factory->create(MultiStepType::class, [], ['steps' => $steps]);
}

/**
* @return iterable<string, array<int, array<string, mixed>>>
*/
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<string, mixed> $steps
*/
public function testConfigureOptionsMustBeClassStringOrCallable(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<string, array<int, array<string, mixed>>>
*/
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 testConfigureOptionsStepNameMustBeString(mixed $steps)
{
self::expectException(InvalidOptionsException::class);

$this->factory->create(MultiStepType::class, [], ['steps' => ['step1' => static function (): void {}], 'current_step' => $steps]);
}

/**
* @return iterable<string, array<int, array<string, mixed>>>
*/
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 testBuildViewHasSteps()
public function testConfigureOptionsStepNameMustExistInSteps()
{
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']);
}


public function testConfigureOptionsSetsDefaultValueForCurrentStepName()
{
$form = $this->factory->create(MultiStepType::class, [], [
'steps' => [
'general' => static function (): void {},
'contact' => static function (): void {},
'newsletter' => static function (): void {},
'step1' => static function (): void {},
'step2' => static function (): void {},
'step3' => static function (): void {},
],
]);

self::assertSame(['general', 'contact', 'newsletter'], $form->createView()->vars['steps']);
self::assertSame('step1', $form->createView()->vars['current_step']);
}

public function testFormOnlyHasCurrentStepForm()
public function testBuildFormStepCanBeCallable()
{
$form = $this->factory->create(MultiStepType::class, [], [
'current_step' => 'contact',
'steps' => [
'general' => static function (FormBuilderInterface $builder): void {
$builder
Expand All @@ -70,17 +134,14 @@ public function testFormOnlyHasCurrentStepForm()
->add('address', TextType::class)
->add('city', TextType::class);
},
'newsletter' => static function (): void {},
],
]);

self::assertArrayHasKey('firstName', $form->createView()->children);
self::assertArrayHasKey('lastName', $form->createView()->children);
self::assertArrayNotHasKey('address', $form->createView()->children);
self::assertArrayNotHasKey('city', $form->createView()->children);
self::assertArrayHasKey('address', $form->createView()->children);
self::assertArrayHasKey('city', $form->createView()->children);
}

public function testFormStepCanBeClassString()
public function testBuildFormStepCanBeClassString()
{
$form = $this->factory->create(MultiStepType::class, [], [
'current_step' => 'author',
Expand All @@ -90,77 +151,67 @@ public function testFormStepCanBeClassString()
->add('firstName', TextType::class)
->add('lastName', TextType::class);
},
'contact' => static function (FormBuilderInterface $builder): void {
$builder
->add('address', TextType::class)
->add('city', TextType::class);
},
'author' => AuthorType::class,
],
]);

self::assertArrayHasKey('author', $form->createView()->children);
}

public function testFormStepWithNormalStringWillThrowException()
public function testBuildView()
{
self::expectException(\InvalidArgumentException::class);
self::expectExceptionMessage('The form class "hello there" does not exist.');

$this->factory->create(MultiStepType::class, [], [
'current_step' => 'author',
$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);
},
'author' => 'hello there',
'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 testFormStepWithClassStringNotExtendingAbstractTypeWillThrowException()
public function testBuildViewIsLastStep()
{
self::expectException(\InvalidArgumentException::class);
self::expectExceptionMessage('"stdClass" is not a form type.');

$this->factory->create(MultiStepType::class, [], [
'current_step' => 'author',
$form = $this->factory->create(MultiStepType::class, [], [
'current_step' => 'newsletter',
'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);
},
'author' => \stdClass::class,
'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 testFormStepsWithInvalidConfiguration()
public function testBuildViewStepIsNotLastAndNotFirst()
{
self::expectException(\InvalidArgumentException::class);
self::expectExceptionMessage('The option "steps" must be an associative array.');

$this->factory->create(MultiStepType::class, [], [
$form = $this->factory->create(MultiStepType::class, [], [
'current_step' => 'general',
'steps' => [
1 => static function (FormBuilderInterface $builder): void {
$builder
->add('firstName', TextType::class)
->add('lastName', TextType::class);
},
'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']);
}
}
Loading
0