From c4a691ab593fd25f7cc5c26c11a48c015c369f3d Mon Sep 17 00:00:00 2001 From: Yonel Ceruto Date: Tue, 18 Mar 2025 12:12:45 -0400 Subject: [PATCH] Add FormFlow for multistep forms management --- .../Controller/AbstractController.php | 6 + .../FrameworkBundle/Resources/config/form.php | 5 + .../Bundle/FrameworkBundle/composer.json | 4 +- src/Symfony/Component/Form/CHANGELOG.md | 5 + .../Form/Extension/Core/CoreExtension.php | 3 + .../Core/Type/FormFlowActionType.php | 93 ++ .../Core/Type/FormFlowNavigatorType.php | 48 + .../Form/Extension/Core/Type/FormFlowType.php | 119 +++ .../HttpFoundationExtension.php | 1 + ...ormFlowTypeSessionDataStorageExtension.php | 45 + .../Component/Form/Flow/AbstractFlowType.php | 55 ++ .../Component/Form/Flow/ActionButton.php | 79 ++ .../Form/Flow/ActionButtonBuilder.php | 27 + .../Form/Flow/ActionButtonInterface.php | 35 + .../Form/Flow/ActionButtonTypeInterface.php | 23 + .../DataAccessor/PropertyPathStepAccessor.php | 37 + .../DataAccessor/StepAccessorInterface.php | 24 + .../Flow/DataStorage/DataStorageInterface.php | 26 + .../Flow/DataStorage/InMemoryDataStorage.php | 40 + .../Form/Flow/DataStorage/NullDataStorage.php | 33 + .../Flow/DataStorage/SessionDataStorage.php | 41 + src/Symfony/Component/Form/Flow/FormFlow.php | 268 +++++ .../Component/Form/Flow/FormFlowBuilder.php | 249 +++++ .../Form/Flow/FormFlowBuilderInterface.php | 49 + .../Form/Flow/FormFlowConfigInterface.php | 46 + .../Component/Form/Flow/FormFlowCursor.php | 103 ++ .../Component/Form/Flow/FormFlowInterface.php | 43 + .../Form/Flow/FormFlowStepBuilder.php | 112 +++ .../Flow/FormFlowStepBuilderInterface.php | 30 + .../Form/Flow/FormFlowStepConfigInterface.php | 24 + .../Form/Flow/FormFlowTypeInterface.php | 23 + src/Symfony/Component/Form/FormFactory.php | 5 + .../Component/Form/FormFactoryInterface.php | 18 + .../Component/Form/ResolvedFormType.php | 12 + .../Tests/Fixtures/Flow/Data/UserSignUp.php | 40 + .../Extension/UserSignUpTypeExtension.php | 35 + .../Flow/Step/UserSignUpAccountType.php | 34 + .../Flow/Step/UserSignUpPersonalType.php | 35 + .../Flow/Step/UserSignUpProfessionalType.php | 39 + .../Fixtures/Flow/UserSignUpNavigatorType.php | 39 + .../Tests/Fixtures/Flow/UserSignUpType.php | 50 + .../Form/Tests/Flow/FormFlowTest.php | 918 ++++++++++++++++++ 42 files changed, 2919 insertions(+), 2 deletions(-) create mode 100644 src/Symfony/Component/Form/Extension/Core/Type/FormFlowActionType.php create mode 100644 src/Symfony/Component/Form/Extension/Core/Type/FormFlowNavigatorType.php create mode 100644 src/Symfony/Component/Form/Extension/Core/Type/FormFlowType.php create mode 100644 src/Symfony/Component/Form/Extension/HttpFoundation/Type/FormFlowTypeSessionDataStorageExtension.php create mode 100644 src/Symfony/Component/Form/Flow/AbstractFlowType.php create mode 100644 src/Symfony/Component/Form/Flow/ActionButton.php create mode 100644 src/Symfony/Component/Form/Flow/ActionButtonBuilder.php create mode 100644 src/Symfony/Component/Form/Flow/ActionButtonInterface.php create mode 100644 src/Symfony/Component/Form/Flow/ActionButtonTypeInterface.php create mode 100644 src/Symfony/Component/Form/Flow/DataAccessor/PropertyPathStepAccessor.php create mode 100644 src/Symfony/Component/Form/Flow/DataAccessor/StepAccessorInterface.php create mode 100644 src/Symfony/Component/Form/Flow/DataStorage/DataStorageInterface.php create mode 100644 src/Symfony/Component/Form/Flow/DataStorage/InMemoryDataStorage.php create mode 100644 src/Symfony/Component/Form/Flow/DataStorage/NullDataStorage.php create mode 100644 src/Symfony/Component/Form/Flow/DataStorage/SessionDataStorage.php create mode 100644 src/Symfony/Component/Form/Flow/FormFlow.php create mode 100644 src/Symfony/Component/Form/Flow/FormFlowBuilder.php create mode 100644 src/Symfony/Component/Form/Flow/FormFlowBuilderInterface.php create mode 100644 src/Symfony/Component/Form/Flow/FormFlowConfigInterface.php create mode 100644 src/Symfony/Component/Form/Flow/FormFlowCursor.php create mode 100644 src/Symfony/Component/Form/Flow/FormFlowInterface.php create mode 100644 src/Symfony/Component/Form/Flow/FormFlowStepBuilder.php create mode 100644 src/Symfony/Component/Form/Flow/FormFlowStepBuilderInterface.php create mode 100644 src/Symfony/Component/Form/Flow/FormFlowStepConfigInterface.php create mode 100644 src/Symfony/Component/Form/Flow/FormFlowTypeInterface.php create mode 100644 src/Symfony/Component/Form/Tests/Fixtures/Flow/Data/UserSignUp.php create mode 100644 src/Symfony/Component/Form/Tests/Fixtures/Flow/Extension/UserSignUpTypeExtension.php create mode 100644 src/Symfony/Component/Form/Tests/Fixtures/Flow/Step/UserSignUpAccountType.php create mode 100644 src/Symfony/Component/Form/Tests/Fixtures/Flow/Step/UserSignUpPersonalType.php create mode 100644 src/Symfony/Component/Form/Tests/Fixtures/Flow/Step/UserSignUpProfessionalType.php create mode 100644 src/Symfony/Component/Form/Tests/Fixtures/Flow/UserSignUpNavigatorType.php create mode 100644 src/Symfony/Component/Form/Tests/Fixtures/Flow/UserSignUpType.php create mode 100644 src/Symfony/Component/Form/Tests/Flow/FormFlowTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php b/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php index de7395d5a83f7..9dbf7d3f459d3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php +++ b/src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php @@ -17,9 +17,13 @@ use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface; use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Flow\FormFlowBuilderInterface; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\Flow\FormFlowTypeInterface; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormTypeInterface; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; use Symfony\Component\HttpFoundation\JsonResponse; @@ -345,6 +349,8 @@ protected function createAccessDeniedException(string $message = 'Access Denied. /** * Creates and returns a Form instance from the type of the form. + * + * @return ($type is class-string ? FormFlowInterface : FormInterface) */ protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.php index 3c936a284b325..e83457744dd9c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.php @@ -24,6 +24,7 @@ use Symfony\Component\Form\Extension\DependencyInjection\DependencyInjectionExtension; use Symfony\Component\Form\Extension\HtmlSanitizer\Type\TextTypeHtmlSanitizerExtension; use Symfony\Component\Form\Extension\HttpFoundation\HttpFoundationRequestHandler; +use Symfony\Component\Form\Extension\HttpFoundation\Type\FormFlowTypeSessionDataStorageExtension; use Symfony\Component\Form\Extension\HttpFoundation\Type\FormTypeHttpFoundationExtension; use Symfony\Component\Form\Extension\Validator\Type\FormTypeValidatorExtension; use Symfony\Component\Form\Extension\Validator\Type\RepeatedTypeValidatorExtension; @@ -123,6 +124,10 @@ ->args([service('form.type_extension.form.request_handler')]) ->tag('form.type_extension') + ->set('form.type_extension.form.flow.session_data_storage', FormFlowTypeSessionDataStorageExtension::class) + ->args([service('request_stack')->ignoreOnInvalid()]) + ->tag('form.type_extension') + ->set('form.type_extension.form.request_handler', HttpFoundationRequestHandler::class) ->args([service('form.server_params')]) diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 15a9496d11067..1e00a12c64393 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -45,7 +45,7 @@ "symfony/dom-crawler": "^6.4|^7.0", "symfony/dotenv": "^6.4|^7.0", "symfony/polyfill-intl-icu": "~1.0", - "symfony/form": "^6.4|^7.0", + "symfony/form": "^7.4", "symfony/expression-language": "^6.4|^7.0", "symfony/html-sanitizer": "^6.4|^7.0", "symfony/http-client": "^6.4|^7.0", @@ -88,7 +88,7 @@ "symfony/dotenv": "<6.4", "symfony/dom-crawler": "<6.4", "symfony/http-client": "<6.4", - "symfony/form": "<6.4", + "symfony/form": "<7.4", "symfony/json-streamer": ">=7.4", "symfony/lock": "<6.4", "symfony/mailer": "<6.4", diff --git a/src/Symfony/Component/Form/CHANGELOG.md b/src/Symfony/Component/Form/CHANGELOG.md index 00d3b2fc4027b..8af77a1f04cac 100644 --- a/src/Symfony/Component/Form/CHANGELOG.md +++ b/src/Symfony/Component/Form/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.4 +--- + + * Add `FormFlow` component for multistep forms management + 7.3 --- diff --git a/src/Symfony/Component/Form/Extension/Core/CoreExtension.php b/src/Symfony/Component/Form/Extension/Core/CoreExtension.php index 1640ed05246ac..4f0ce48a9f598 100644 --- a/src/Symfony/Component/Form/Extension/Core/CoreExtension.php +++ b/src/Symfony/Component/Form/Extension/Core/CoreExtension.php @@ -78,6 +78,9 @@ protected function loadTypes(): array new Type\TelType(), new Type\ColorType($this->translator), new Type\WeekType(), + new Type\FormFlowActionType(), + new Type\FormFlowNavigatorType(), + new Type\FormFlowType($this->propertyAccessor), ]; } diff --git a/src/Symfony/Component/Form/Extension/Core/Type/FormFlowActionType.php b/src/Symfony/Component/Form/Extension/Core/Type/FormFlowActionType.php new file mode 100644 index 0000000000000..3a0072f6ad3c2 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/Core/Type/FormFlowActionType.php @@ -0,0 +1,93 @@ + + * + * 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\Flow\ActionButtonInterface; +use Symfony\Component\Form\Flow\ActionButtonTypeInterface; +use Symfony\Component\Form\Flow\FormFlowCursor; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * An action-based submit button for a form flow. + * + * @author Yonel Ceruto + */ +class FormFlowActionType extends AbstractType implements ActionButtonTypeInterface +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->define('action') + ->info('The action name of the button') + ->default('') + ->allowedTypes('string'); + + $resolver->define('handler') + ->info('A callable that will be called when this button is clicked') + ->default(function (Options $options) { + if (!\in_array($options['action'], ['back', 'next', 'finish', 'reset'], true)) { + throw new MissingOptionsException(\sprintf('The option "handler" is required for the action "%s".', $options['action'])); + } + + return function (mixed $data, ActionButtonInterface $button, FormFlowInterface $flow): void { + match (true) { + $button->isBackAction() => $flow->moveBack($button->getViewData()), + $button->isNextAction() => $flow->moveNext(), + $button->isFinishAction(), $button->isResetAction() => $flow->reset(), + }; + }; + }) + ->allowedTypes('callable'); + + $resolver->define('include_if') + ->info('Decide whether to include this button in the current form') + ->default(function (Options $options) { + return match ($options['action']) { + 'back' => fn (FormFlowCursor $cursor): bool => $cursor->canMoveBack(), + 'next' => fn (FormFlowCursor $cursor): bool => $cursor->canMoveNext(), + 'finish' => fn (FormFlowCursor $cursor): bool => $cursor->isLastStep(), + default => null, + }; + }) + ->allowedTypes('null', 'array', 'callable') + ->normalize(function (Options $options, mixed $value) { + if (\is_array($value)) { + return fn (FormFlowCursor $cursor): bool => \in_array($cursor->getCurrentStep(), $value, true); + } + + return $value; + }); + + $resolver->define('clear_submission') + ->info('Whether the submitted data will be cleared when this button is clicked') + ->default(function (Options $options) { + return 'reset' === $options['action'] || 'back' === $options['action']; + }) + ->allowedTypes('bool'); + + $resolver->setDefault('validate', function (Options $options) { + return !$options['clear_submission']; + }); + + $resolver->setDefault('validation_groups', function (Options $options) { + return $options['clear_submission'] ? false : null; + }); + } + + public function getParent(): string + { + return SubmitType::class; + } +} diff --git a/src/Symfony/Component/Form/Extension/Core/Type/FormFlowNavigatorType.php b/src/Symfony/Component/Form/Extension/Core/Type/FormFlowNavigatorType.php new file mode 100644 index 0000000000000..2139d58787341 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/Core/Type/FormFlowNavigatorType.php @@ -0,0 +1,48 @@ + + * + * 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\OptionsResolver\OptionsResolver; + +/** + * A navigator type that defines default actions to interact with a form flow. + * + * @author Yonel Ceruto + */ +class FormFlowNavigatorType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('back', FormFlowActionType::class, [ + 'action' => 'back', + ]); + + $builder->add('next', FormFlowActionType::class, [ + 'action' => 'next', + ]); + + $builder->add('finish', FormFlowActionType::class, [ + 'action' => 'finish', + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'mapped' => false, + 'priority' => -100, + ]); + } +} diff --git a/src/Symfony/Component/Form/Extension/Core/Type/FormFlowType.php b/src/Symfony/Component/Form/Extension/Core/Type/FormFlowType.php new file mode 100644 index 0000000000000..799a6b62ba298 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/Core/Type/FormFlowType.php @@ -0,0 +1,119 @@ + + * + * 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\Flow\AbstractFlowType; +use Symfony\Component\Form\Flow\DataAccessor\PropertyPathStepAccessor; +use Symfony\Component\Form\Flow\DataAccessor\StepAccessorInterface; +use Symfony\Component\Form\Flow\DataStorage\DataStorageInterface; +use Symfony\Component\Form\Flow\DataStorage\NullDataStorage; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyAccess\PropertyPath; +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +/** + * A multistep form. + * + * @author Yonel Ceruto + */ +class FormFlowType extends AbstractFlowType +{ + public function __construct( + private ?PropertyAccessorInterface $propertyAccessor = null, + ) { + $this->propertyAccessor ??= PropertyAccess::createPropertyAccessor(); + } + + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->setDataStorage($options['data_storage'] ?? new NullDataStorage()); + $builder->setStepAccessor($options['step_accessor']); + } + + /** + * {@inheritdoc} + */ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['cursor'] = $cursor = $form->getCursor(); + + $index = 0; + $position = 1; + foreach ($form->getConfig()->getSteps() as $name => $step) { + $isSkipped = $step->isSkipped($form->getViewData()); + + $stepVars = [ + 'name' => $name, + 'index' => $index++, + 'position' => $isSkipped ? -1 : $position++, + 'is_current_step' => $name === $cursor->getCurrentStep(), + 'can_be_skipped' => null !== $step->getSkip(), + 'is_skipped' => $isSkipped, + ]; + + $view->vars['steps'][$name] = $stepVars; + + if (!$isSkipped) { + $view->vars['visible_steps'][$name] = $stepVars; + } + } + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->define('data_storage') + ->default(null) + ->allowedTypes('null', DataStorageInterface::class); + + $resolver->define('step_accessor') + ->default(function (Options $options) { + if (!isset($options['step_property_path'])) { + throw new MissingOptionsException('Option "step_property_path" is required.'); + } + + return new PropertyPathStepAccessor($this->propertyAccessor, $options['step_property_path']); + }) + ->allowedTypes(StepAccessorInterface::class); + + $resolver->define('step_property_path') + ->info('Required if the default step_accessor is being used') + ->allowedTypes('string', PropertyPathInterface::class) + ->normalize(function (Options $options, string|PropertyPathInterface $value): PropertyPathInterface { + return \is_string($value) ? new PropertyPath($value) : $value; + }); + + $resolver->define('auto_reset') + ->info('Whether the FormFlow will be reset automatically when it is finished') + ->default(true) + ->allowedTypes('bool'); + + $resolver->setDefault('validation_groups', function (FormFlowInterface $flow) { + return ['Default', $flow->getCursor()->getCurrentStep()]; + }); + } + + public function getParent(): string + { + return FormType::class; + } +} diff --git a/src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationExtension.php b/src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationExtension.php index 85bc4f4720b3f..0a17bf216afba 100644 --- a/src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationExtension.php +++ b/src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationExtension.php @@ -24,6 +24,7 @@ protected function loadTypeExtensions(): array { return [ new Type\FormTypeHttpFoundationExtension(), + new Type\FormFlowTypeSessionDataStorageExtension(), ]; } } diff --git a/src/Symfony/Component/Form/Extension/HttpFoundation/Type/FormFlowTypeSessionDataStorageExtension.php b/src/Symfony/Component/Form/Extension/HttpFoundation/Type/FormFlowTypeSessionDataStorageExtension.php new file mode 100644 index 0000000000000..823cf6b3caf41 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/HttpFoundation/Type/FormFlowTypeSessionDataStorageExtension.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Extension\HttpFoundation\Type; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\FormFlowType; +use Symfony\Component\Form\Flow\DataStorage\SessionDataStorage; +use Symfony\Component\Form\Flow\FormFlowBuilderInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\HttpFoundation\RequestStack; + +class FormFlowTypeSessionDataStorageExtension extends AbstractTypeExtension +{ + public function __construct( + private readonly ?RequestStack $requestStack = null, + ) { + } + + /** + * @param FormFlowBuilderInterface $builder + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + if (null === $this->requestStack || null !== $options['data_storage']) { + return; + } + + $key = \sprintf('_sf_formflow.%s_%s', strtolower(str_replace('\\', '_', $builder->getType()->getInnerType()::class)), $builder->getName()); + $builder->setDataStorage(new SessionDataStorage($key, $this->requestStack)); + } + + public static function getExtendedTypes(): iterable + { + yield FormFlowType::class; + } +} diff --git a/src/Symfony/Component/Form/Flow/AbstractFlowType.php b/src/Symfony/Component/Form/Flow/AbstractFlowType.php new file mode 100644 index 0000000000000..8860f87c6cf58 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/AbstractFlowType.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\FormFlowType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Yonel Ceruto + */ +abstract class AbstractFlowType extends AbstractType implements FormFlowTypeInterface +{ + /** + * @param FormFlowBuilderInterface $builder + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + } + + /** + * @param FormFlowInterface $form + */ + public function buildView(FormView $view, FormInterface $form, array $options): void + { + } + + /** + * @param FormFlowInterface $form + */ + public function finishView(FormView $view, FormInterface $form, array $options): void + { + } + + public function configureOptions(OptionsResolver $resolver): void + { + } + + public function getParent(): string + { + return FormFlowType::class; + } +} diff --git a/src/Symfony/Component/Form/Flow/ActionButton.php b/src/Symfony/Component/Form/Flow/ActionButton.php new file mode 100644 index 0000000000000..5289ae87104db --- /dev/null +++ b/src/Symfony/Component/Form/Flow/ActionButton.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +use Symfony\Component\Form\SubmitButton; + +/** + * A button that submits the form and handles an action. + * + * @author Yonel Ceruto + */ +class ActionButton extends SubmitButton implements ActionButtonInterface +{ + private mixed $data = null; + + public function submit(array|string|null $submittedData, bool $clearMissing = true): static + { + if ($this->isSubmitted()) { + return $this; // ignore double submit + } + + parent::submit($submittedData, $clearMissing); + + if ($this->isSubmitted()) { + $this->data = $submittedData; + } + + return $this; + } + + public function getViewData(): mixed + { + return $this->data; + } + + public function getAction(): string + { + return $this->getConfig()->getOption('action'); + } + + public function getHandler(): callable + { + return $this->getConfig()->getOption('handler'); + } + + public function isResetAction(): bool + { + return 'reset' === $this->getAction(); + } + + public function isBackAction(): bool + { + return 'back' === $this->getAction(); + } + + public function isNextAction(): bool + { + return 'next' === $this->getAction(); + } + + public function isFinishAction(): bool + { + return 'finish' === $this->getAction(); + } + + public function isClearSubmission(): bool + { + return $this->getConfig()->getOption('clear_submission'); + } +} diff --git a/src/Symfony/Component/Form/Flow/ActionButtonBuilder.php b/src/Symfony/Component/Form/Flow/ActionButtonBuilder.php new file mode 100644 index 0000000000000..7aedb957f3fc7 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/ActionButtonBuilder.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +use Symfony\Component\Form\ButtonBuilder; + +/** + * A builder for {@link ActionButton} instances. + * + * @author Yonel Ceruto + */ +class ActionButtonBuilder extends ButtonBuilder +{ + public function getForm(): ActionButton + { + return new ActionButton($this->getFormConfig()); + } +} diff --git a/src/Symfony/Component/Form/Flow/ActionButtonInterface.php b/src/Symfony/Component/Form/Flow/ActionButtonInterface.php new file mode 100644 index 0000000000000..f8f398751bfb6 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/ActionButtonInterface.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +use Symfony\Component\Form\ClickableInterface; +use Symfony\Component\Form\FormInterface; + +/** + * @author Yonel Ceruto + */ +interface ActionButtonInterface extends FormInterface, ClickableInterface +{ + public function getAction(): string; + + public function getHandler(): callable; + + public function isResetAction(): bool; + + public function isBackAction(): bool; + + public function isNextAction(): bool; + + public function isFinishAction(): bool; + + public function isClearSubmission(): bool; +} diff --git a/src/Symfony/Component/Form/Flow/ActionButtonTypeInterface.php b/src/Symfony/Component/Form/Flow/ActionButtonTypeInterface.php new file mode 100644 index 0000000000000..6d08069684847 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/ActionButtonTypeInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +use Symfony\Component\Form\FormTypeInterface; + +/** + * A type that should be converted into a {@link ActionButton} instance. + * + * @author Yonel Ceruto + */ +interface ActionButtonTypeInterface extends FormTypeInterface +{ +} diff --git a/src/Symfony/Component/Form/Flow/DataAccessor/PropertyPathStepAccessor.php b/src/Symfony/Component/Form/Flow/DataAccessor/PropertyPathStepAccessor.php new file mode 100644 index 0000000000000..d2e63ee73e0a7 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/DataAccessor/PropertyPathStepAccessor.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow\DataAccessor; + +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +/** + * @author Yonel Ceruto + */ +class PropertyPathStepAccessor implements StepAccessorInterface +{ + public function __construct( + private readonly PropertyAccessorInterface $propertyAccessor, + private readonly PropertyPathInterface $propertyPath, + ) { + } + + public function getStep(object|array $data, ?string $default = null): ?string + { + return $this->propertyAccessor->getValue($data, $this->propertyPath) ?: $default; + } + + public function setStep(object|array &$data, string $step): void + { + $this->propertyAccessor->setValue($data, $this->propertyPath, $step); + } +} diff --git a/src/Symfony/Component/Form/Flow/DataAccessor/StepAccessorInterface.php b/src/Symfony/Component/Form/Flow/DataAccessor/StepAccessorInterface.php new file mode 100644 index 0000000000000..41d4801c7973e --- /dev/null +++ b/src/Symfony/Component/Form/Flow/DataAccessor/StepAccessorInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow\DataAccessor; + +/** + * Reads from or writes the current step name to a provided data source. + * + * @author Yonel Ceruto + */ +interface StepAccessorInterface +{ + public function getStep(object|array $data, ?string $default = null): ?string; + + public function setStep(object|array &$data, string $step): void; +} diff --git a/src/Symfony/Component/Form/Flow/DataStorage/DataStorageInterface.php b/src/Symfony/Component/Form/Flow/DataStorage/DataStorageInterface.php new file mode 100644 index 0000000000000..5b9f188a79add --- /dev/null +++ b/src/Symfony/Component/Form/Flow/DataStorage/DataStorageInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow\DataStorage; + +/** + * Handles storing and retrieving form data between steps. + * + * @author Yonel Ceruto + */ +interface DataStorageInterface +{ + public function save(object|array $data): void; + + public function load(object|array|null $default = null): object|array|null; + + public function clear(): void; +} diff --git a/src/Symfony/Component/Form/Flow/DataStorage/InMemoryDataStorage.php b/src/Symfony/Component/Form/Flow/DataStorage/InMemoryDataStorage.php new file mode 100644 index 0000000000000..b1eb12f518473 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/DataStorage/InMemoryDataStorage.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow\DataStorage; + +/** + * @author Yonel Ceruto + */ +class InMemoryDataStorage implements DataStorageInterface +{ + private array $memory = []; + + public function __construct( + private readonly string $key, + ) { + } + + public function save(object|array $data): void + { + $this->memory[$this->key] = $data; + } + + public function load(object|array|null $default = null): object|array|null + { + return $this->memory[$this->key] ?? $default; + } + + public function clear(): void + { + unset($this->memory[$this->key]); + } +} diff --git a/src/Symfony/Component/Form/Flow/DataStorage/NullDataStorage.php b/src/Symfony/Component/Form/Flow/DataStorage/NullDataStorage.php new file mode 100644 index 0000000000000..09ad2f78e005a --- /dev/null +++ b/src/Symfony/Component/Form/Flow/DataStorage/NullDataStorage.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow\DataStorage; + +/** + * @author Yonel Ceruto + */ +final class NullDataStorage implements DataStorageInterface +{ + public function save(object|array $data): void + { + // no-op + } + + public function load(object|array|null $default = null): object|array|null + { + return $default; + } + + public function clear(): void + { + // no-op + } +} diff --git a/src/Symfony/Component/Form/Flow/DataStorage/SessionDataStorage.php b/src/Symfony/Component/Form/Flow/DataStorage/SessionDataStorage.php new file mode 100644 index 0000000000000..44f4445b64972 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/DataStorage/SessionDataStorage.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow\DataStorage; + +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * @author Yonel Ceruto + */ +class SessionDataStorage implements DataStorageInterface +{ + public function __construct( + private readonly string $key, + private readonly RequestStack $requestStack, + ) { + } + + public function save(object|array $data): void + { + $this->requestStack->getSession()->set($this->key, $data); + } + + public function load(object|array|null $default = null): object|array|null + { + return $this->requestStack->getSession()->get($this->key, $default); + } + + public function clear(): void + { + $this->requestStack->getSession()->remove($this->key); + } +} diff --git a/src/Symfony/Component/Form/Flow/FormFlow.php b/src/Symfony/Component/Form/Flow/FormFlow.php new file mode 100644 index 0000000000000..5764ba9e83f1e --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlow.php @@ -0,0 +1,268 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +use Symfony\Component\Form\Exception\AlreadySubmittedException; +use Symfony\Component\Form\Exception\InvalidArgumentException; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Form\Exception\RuntimeException; +use Symfony\Component\Form\Exception\TransformationFailedException; +use Symfony\Component\Form\Form; +use Symfony\Component\Form\FormInterface; + +/** + * FormFlow represents a multistep form. + * + * @author Yonel Ceruto + * + * @implements \IteratorAggregate + */ +class FormFlow extends Form implements FormFlowInterface +{ + private ?ActionButtonInterface $clickedActionButton = null; + private bool $finished = false; + private bool $handled = false; + + public function __construct( + private readonly FormFlowConfigInterface $config, + private FormFlowCursor $cursor, + ) { + parent::__construct($config); + } + + public function submit(mixed $submittedData, bool $clearMissing = true): static + { + if ($this->isSubmitted()) { + throw new AlreadySubmittedException('A form can only be submitted once.'); + } + + if (!\is_array($submittedData)) { + throw new TransformationFailedException('The submitted data must be an array.'); + } + + if (!$this->isCurrentStepSubmitted($submittedData)) { + // the submitted data doesn't match the current step, + // it's probably a reload of a POST visit from a different step + return $this; + } + + $this->setClickedActionButton($submittedData, $this); + + if ($this->clickedActionButton?->isClearSubmission()) { + $submittedData = []; + } + + parent::submit($submittedData, $clearMissing); + + if (!$this->clickedActionButton || !$this->isSubmitted() || !$this->isValid()) { + return $this; + } + + $this->finished = $this->clickedActionButton->isFinishAction(); + + if ($this->finished && $this->config->isAutoReset()) { + $this->reset(); + } + + return $this; + } + + public function handleAction(): void + { + if (!$this->clickedActionButton) { + throw new LogicException('No action button was clicked.'); + } + + /** @var FormInterface $form */ + $form = $this->clickedActionButton->getParent(); + $handler = $this->clickedActionButton->getHandler(); + $handler($form->getData(), $this->clickedActionButton, $this); + + $this->handled = true; + } + + public function reset(): void + { + $this->config->getDataStorage()->clear(); + $this->cursor = $this->cursor->withCurrentStep($this->config->getInitialStep()); + } + + public function moveBack(?string $step = null): void + { + if ($step) { + $this->moveBackTo($step); + + return; + } + + if (!$this->move(fn (FormFlowCursor $cursor) => $cursor->getPrevStep())) { + throw new RuntimeException('Cannot determine previous step.'); + } + } + + public function moveNext(): void + { + if (!$this->move(fn (FormFlowCursor $cursor) => $cursor->getNextStep())) { + throw new RuntimeException('Cannot determine next step.'); + } + } + + public function createStepForm(): static + { + return $this->config->getFormFactory()->createNamed($this->config->getName(), $this->config->getType()->getInnerType()::class, $this->getData(), $this->config->getInitialOptions()); + } + + public function getStepForm(): static + { + if (!$this->isSubmitted() || !$this->isValid()) { + return $this; + } + + if ($this->clickedActionButton && !$this->handled) { + $this->handleAction(); + } + + if (!$this->isValid()) { + return $this; + } + + return $this->createStepForm(); + } + + public function getCursor(): FormFlowCursor + { + return $this->cursor; + } + + public function getConfig(): FormFlowConfigInterface + { + return $this->config; + } + + public function isFinished(): bool + { + return $this->finished; + } + + public function getClickedActionButton(): ?ActionButtonInterface + { + return $this->clickedActionButton; + } + + private function setClickedActionButton(mixed $submittedData, FormInterface $form): void + { + if (!\is_array($submittedData)) { + return; + } + + foreach ($form as $name => $child) { + if (!\array_key_exists($name, $submittedData)) { + continue; + } + + if ($child->count() > 0) { + $this->setClickedActionButton($submittedData[$name], $child); + + if ($this->clickedActionButton) { + return; + } + + continue; + } + + if (!$child instanceof ActionButtonInterface) { + continue; + } + + $child->submit($submittedData[$name]); + + if ($child->isClicked()) { + $this->clickedActionButton = $child; + break; + } + } + } + + private function moveBackTo(string $step): void + { + $steps = $this->cursor->getSteps(); + + if (false === $targetIndex = array_search($step, $steps)) { + throw new InvalidArgumentException(\sprintf('Step "%s" does not exist.', $step)); + } + + $currentStep = $this->cursor->getCurrentStep(); + $currentIndex = $this->cursor->getStepIndex(); + + if ($targetIndex === $currentIndex) { + return; + } + + if ($targetIndex > $currentIndex) { + throw new RuntimeException(\sprintf('Cannot move back to step "%s" because it is ahead of the current step "%s".', $step, $currentStep)); + } + + while ($targetIndex < $currentIndex) { + $this->moveBack(); + $currentIndex = $this->cursor->getStepIndex(); + } + + if ($targetIndex > $currentIndex) { + throw new RuntimeException(\sprintf('Cannot move back to step "%s" because it is a skipped step.', $step)); + } + } + + private function move(\Closure $direction): bool + { + $data = $this->getData(); + $cursor = $this->cursor; + $stepsPath = []; + + while (true) { + if (null === $newStep = $direction($cursor)) { + return false; + } + + if (isset($stepsPath[$newStep])) { + throw new RuntimeException(\sprintf('Circular step transition detected: "%s" > "%s".', implode('" > "', array_keys($stepsPath)), $newStep)); + } + + if ($cursor->getCurrentStep() === $newStep) { + return true; + } + + $stepsPath[$newStep] = true; + $cursor = $cursor->withCurrentStep($newStep); + + if (!$this->config->getStep($newStep)->isSkipped($data)) { + break; + } + } + + $this->cursor = $cursor; + $this->config->getStepAccessor()->setStep($data, $newStep); + $this->config->getDataStorage()->save($data); + + return true; + } + + private function isCurrentStepSubmitted(array $submittedData): bool + { + foreach ($this->cursor->getSteps() as $step) { + if (\array_key_exists($step, $submittedData)) { + return $step === $this->cursor->getCurrentStep(); + } + } + + return true; + } +} diff --git a/src/Symfony/Component/Form/Flow/FormFlowBuilder.php b/src/Symfony/Component/Form/Flow/FormFlowBuilder.php new file mode 100644 index 0000000000000..65c5d412951d6 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlowBuilder.php @@ -0,0 +1,249 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +use Symfony\Component\Form\Exception\BadMethodCallException; +use Symfony\Component\Form\Exception\InvalidArgumentException; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Flow\DataAccessor\StepAccessorInterface; +use Symfony\Component\Form\Flow\DataStorage\DataStorageInterface; +use Symfony\Component\Form\FormBuilder; +use Symfony\Component\Form\FormBuilderInterface; + +/** + * A builder for creating {@link FormFlow} instances. + * + * @author Yonel Ceruto + * + * @implements \IteratorAggregate + */ +class FormFlowBuilder extends FormBuilder implements FormFlowBuilderInterface +{ + /** + * @var array + */ + private array $steps = []; + private array $initialOptions = []; + private DataStorageInterface $dataStorage; + private StepAccessorInterface $stepAccessor; + + public function createStep(string $name, string $type = FormType::class, array $options = []): FormFlowStepBuilderInterface + { + if ($this->locked) { + throw new BadMethodCallException('FormFlowBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowConfigInterface instance.'); + } + + return new FormFlowStepBuilder($name, $type, $options); + } + + public function addStep(FormFlowStepBuilderInterface|string $name, string $type = FormType::class, array $options = [], ?callable $skip = null, int $priority = 0): static + { + if ($this->locked) { + throw new BadMethodCallException('FormFlowBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowConfigInterface instance.'); + } + + if ($name instanceof FormFlowStepBuilderInterface) { + $this->steps[$name->getName()] = $name; + + return $this; + } + + $this->steps[$name] = $this->createStep($name, $type, $options) + ->setSkip($skip ? $skip(...) : null) + ->setPriority($priority) + ; + + return $this; + } + + public function removeStep(string $name): static + { + if ($this->locked) { + throw new BadMethodCallException('FormFlowBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowConfigInterface instance.'); + } + + unset($this->steps[$name]); + + return $this; + } + + public function hasStep(string $name): bool + { + return isset($this->steps[$name]); + } + + public function getStep(string $name): FormFlowStepBuilderInterface + { + return $this->steps[$name] ?? throw new InvalidArgumentException(\sprintf('Step "%s" does not exist.', $name)); + } + + public function getSteps(): array + { + return $this->steps; + } + + public function setInitialOptions(array $options): static + { + if ($this->locked) { + throw new BadMethodCallException('FormFlowBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowConfigInterface instance.'); + } + + $this->initialOptions = $options; + + return $this; + } + + public function getInitialStep(): string + { + return $this->stepAccessor->getStep($this->initialOptions['data']) ?: key($this->steps); + } + + public function getInitialOptions(): array + { + return $this->initialOptions; + } + + public function setDataStorage(DataStorageInterface $dataStorage): static + { + if ($this->locked) { + throw new BadMethodCallException('FormFlowBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowConfigInterface instance.'); + } + + $this->dataStorage = $dataStorage; + + // make sure the current data is available immediately + $this->setData($dataStorage->load($this->getData())); + + return $this; + } + + public function getDataStorage(): DataStorageInterface + { + return $this->dataStorage; + } + + public function setStepAccessor(StepAccessorInterface $stepAccessor): static + { + if ($this->locked) { + throw new BadMethodCallException('FormFlowBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowConfigInterface instance.'); + } + + $this->stepAccessor = $stepAccessor; + + return $this; + } + + public function getStepAccessor(): StepAccessorInterface + { + return $this->stepAccessor; + } + + public function isAutoReset(): bool + { + return $this->getOption('auto_reset'); + } + + public function getFormConfig(): FormFlowConfigInterface + { + /** @var self $config */ + $config = parent::getFormConfig(); + + foreach ($config->steps as $name => $step) { + $config->steps[$name] = $step->getStepConfig(); + } + + return $config; + } + + public function getForm(): FormFlowInterface + { + if ($this->locked) { + throw new BadMethodCallException('FormFlowBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowConfigInterface instance.'); + } + + $flow = $this->createFormFlow(); + + foreach ($this->all() as $child) { + if ($child instanceof FormFlowBuilderInterface) { + throw new LogicException('Nested form flows is not currently supported.'); + } + + // Automatic initialization is only supported on root forms + $flow->add($child->setAutoInitialize(false)->getForm()); + } + + if ($this->getAutoInitialize()) { + // Automatically initialize the form if it is configured so + $flow->initialize(); + } + + return $flow; + } + + private function createFormFlow(): FormFlowInterface + { + if (!$this->steps) { + throw new InvalidArgumentException('Steps not configured.'); + } + + uasort($this->steps, static function (FormFlowStepBuilderInterface $a, FormFlowStepBuilderInterface $b) { + return $b->getPriority() <=> $a->getPriority(); + }); + + $currentStep = $this->resolveCurrentStep(); + + if (!isset($this->steps[$currentStep])) { + throw new InvalidArgumentException(\sprintf('Step form "%s" is not defined.', $currentStep)); + } + + $step = $this->steps[$currentStep]; + $this->add($step->getName(), $step->getType(), $step->getOptions()); + + $cursor = new FormFlowCursor(array_keys($this->steps), $currentStep); + $this->pruneActionButtons($this, $cursor); + + return new FormFlow($this->getFormConfig(), $cursor); + } + + private function resolveCurrentStep(): string + { + $data = $this->getData(); + + if (!$currentStep = $this->getStepAccessor()->getStep($data)) { + $currentStep = key($this->steps); + $this->getStepAccessor()->setStep($data, $currentStep); + $this->setData($data); + } + + return $currentStep; + } + + private function pruneActionButtons(FormBuilderInterface $builder, FormFlowCursor $cursor): void + { + foreach ($builder->all() as $child) { + if ($child->count() > 0) { + $this->pruneActionButtons($child, $cursor); + + continue; + } + + if (!$child instanceof ActionButtonBuilder || !\is_callable($include = $child->getOption('include_if'))) { + continue; + } + + if (!$include($cursor)) { + $builder->remove($child->getName()); + } + } + } +} diff --git a/src/Symfony/Component/Form/Flow/FormFlowBuilderInterface.php b/src/Symfony/Component/Form/Flow/FormFlowBuilderInterface.php new file mode 100644 index 0000000000000..8a04221d919ac --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlowBuilderInterface.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Flow\DataAccessor\StepAccessorInterface; +use Symfony\Component\Form\Flow\DataStorage\DataStorageInterface; +use Symfony\Component\Form\FormBuilderInterface; + +/** + * @author Yonel Ceruto + * + * @extends \Traversable + */ +interface FormFlowBuilderInterface extends FormBuilderInterface, FormFlowConfigInterface +{ + public function createStep(string $name, string $type = FormType::class, array $options = []): FormFlowStepBuilderInterface; + + public function addStep(FormFlowStepBuilderInterface|string $name, string $type = FormType::class, array $options = [], ?callable $skip = null, int $priority = 0): static; + + public function removeStep(string $name): static; + + public function getStep(string $name): FormFlowStepBuilderInterface; + + /** + * @return array + */ + public function getSteps(): array; + + /** + * @param array $options + */ + public function setInitialOptions(array $options): static; + + public function setDataStorage(DataStorageInterface $dataStorage): static; + + public function setStepAccessor(StepAccessorInterface $stepAccessor): static; + + public function getForm(): FormFlowInterface; +} diff --git a/src/Symfony/Component/Form/Flow/FormFlowConfigInterface.php b/src/Symfony/Component/Form/Flow/FormFlowConfigInterface.php new file mode 100644 index 0000000000000..182e1c5afe92b --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlowConfigInterface.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +use Symfony\Component\Form\Flow\DataAccessor\StepAccessorInterface; +use Symfony\Component\Form\Flow\DataStorage\DataStorageInterface; +use Symfony\Component\Form\FormConfigInterface; + +/** + * The configuration of a {@link FormFlow} object. + * + * @author Yonel Ceruto + */ +interface FormFlowConfigInterface extends FormConfigInterface +{ + public function hasStep(string $name): bool; + + public function getStep(string $name): FormFlowStepConfigInterface; + + /** + * @return array + */ + public function getSteps(): array; + + public function getInitialStep(): string; + + /** + * @return array + */ + public function getInitialOptions(): array; + + public function getDataStorage(): DataStorageInterface; + + public function getStepAccessor(): StepAccessorInterface; + + public function isAutoReset(): bool; +} diff --git a/src/Symfony/Component/Form/Flow/FormFlowCursor.php b/src/Symfony/Component/Form/Flow/FormFlowCursor.php new file mode 100644 index 0000000000000..815acbc079939 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlowCursor.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +use Symfony\Component\Form\Exception\InvalidArgumentException; + +/** + * @author Yonel Ceruto + */ +class FormFlowCursor +{ + /** + * @param array $steps + */ + public function __construct( + private readonly array $steps, + private readonly string $currentStep, + ) { + if (!\in_array($currentStep, $steps, true)) { + throw new InvalidArgumentException(\sprintf('Step "%s" does not exist. Available steps are: "%s".', $currentStep, implode('", "', array_keys($steps)))); + } + } + + public function getSteps(): array + { + return $this->steps; + } + + public function getTotalSteps(): int + { + return \count($this->steps); + } + + public function getStepIndex(): int + { + return array_search($this->currentStep, $this->steps, true); + } + + public function getFirstStep(): string + { + return $this->steps[0]; + } + + public function getPrevStep(): ?string + { + $currentPos = array_search($this->currentStep, $this->steps, true); + + return $this->steps[$currentPos - 1] ?? null; + } + + public function getCurrentStep(): string + { + return $this->currentStep; + } + + public function withCurrentStep(string $step): self + { + return new self($this->steps, $step); + } + + public function getNextStep(): ?string + { + $currentPos = array_search($this->currentStep, $this->steps, true); + + return $this->steps[$currentPos + 1] ?? null; + } + + public function getLastStep(): string + { + return $this->steps[\count($this->steps) - 1]; + } + + public function isFirstStep(): bool + { + return 0 === array_search($this->currentStep, $this->steps, true); + } + + public function isLastStep(): bool + { + $currentPos = array_search($this->currentStep, $this->steps, true); + + return \count($this->steps) === $currentPos + 1; + } + + public function canMoveBack(): bool + { + return null !== $this->getPrevStep(); + } + + public function canMoveNext(): bool + { + return null !== $this->getNextStep(); + } +} diff --git a/src/Symfony/Component/Form/Flow/FormFlowInterface.php b/src/Symfony/Component/Form/Flow/FormFlowInterface.php new file mode 100644 index 0000000000000..67a4d6ececb44 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlowInterface.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +use Symfony\Component\Form\FormInterface; + +/** + * @author Yonel Ceruto + */ +interface FormFlowInterface extends FormInterface +{ + public function getClickedActionButton(): ?ActionButtonInterface; + + public function handleAction(): void; + + public function reset(): void; + + /** + * @param string|null $step The step to move back to, or null to move back one step + */ + public function moveBack(?string $step = null): void; + + public function moveNext(): void; + + public function createStepForm(): static; + + public function getStepForm(): static; + + public function getCursor(): FormFlowCursor; + + public function getConfig(): FormFlowConfigInterface; + + public function isFinished(): bool; +} diff --git a/src/Symfony/Component/Form/Flow/FormFlowStepBuilder.php b/src/Symfony/Component/Form/Flow/FormFlowStepBuilder.php new file mode 100644 index 0000000000000..fd8d1529bb94e --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlowStepBuilder.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +use Symfony\Component\Form\Exception\BadMethodCallException; +use Symfony\Component\Form\FormTypeInterface; + +/** + * @author Yonel Ceruto + */ +class FormFlowStepBuilder implements FormFlowStepBuilderInterface +{ + private bool $locked = false; + private int $priority = 0; + private ?\Closure $skip = null; + + /** + * @param class-string $type + */ + public function __construct( + private readonly string $name, + private readonly string $type, + private readonly array $options = [], + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function getType(): string + { + if ($this->locked) { + throw new BadMethodCallException('FormFlowStepBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowStepConfigInterface instance.'); + } + + return $this->type; + } + + public function getOptions(): array + { + if ($this->locked) { + throw new BadMethodCallException('FormFlowStepBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowStepConfigInterface instance.'); + } + + return $this->options; + } + + public function getPriority(): int + { + return $this->priority; + } + + public function setPriority(int $priority): static + { + if ($this->locked) { + throw new BadMethodCallException('FormFlowStepBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowStepConfigInterface instance.'); + } + + $this->priority = $priority; + + return $this; + } + + public function getSkip(): ?\Closure + { + return $this->skip; + } + + public function isSkipped(mixed $data): bool + { + if (null === $this->skip) { + return false; + } + + return ($this->skip)($data); + } + + public function setSkip(?\Closure $skip): static + { + if ($this->locked) { + throw new BadMethodCallException('FormFlowStepBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowStepConfigInterface instance.'); + } + + $this->skip = $skip; + + return $this; + } + + public function getStepConfig(): FormFlowStepConfigInterface + { + if ($this->locked) { + throw new BadMethodCallException('FormFlowStepBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowStepConfigInterface instance.'); + } + + // This method should be idempotent, so clone the builder + $config = clone $this; + $config->locked = true; + + return $config; + } +} diff --git a/src/Symfony/Component/Form/Flow/FormFlowStepBuilderInterface.php b/src/Symfony/Component/Form/Flow/FormFlowStepBuilderInterface.php new file mode 100644 index 0000000000000..cf98e38cc4a79 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlowStepBuilderInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +/** + * @author Yonel Ceruto + */ +interface FormFlowStepBuilderInterface extends FormFlowStepConfigInterface +{ + public function getType(): string; + + public function getOptions(): array; + + public function getPriority(): int; + + public function setPriority(int $priority): static; + + public function setSkip(?\Closure $skip): static; + + public function getStepConfig(): FormFlowStepConfigInterface; +} diff --git a/src/Symfony/Component/Form/Flow/FormFlowStepConfigInterface.php b/src/Symfony/Component/Form/Flow/FormFlowStepConfigInterface.php new file mode 100644 index 0000000000000..bc05dd43c1e6c --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlowStepConfigInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +/** + * @author Yonel Ceruto + */ +interface FormFlowStepConfigInterface +{ + public function getName(): string; + + public function getSkip(): ?\Closure; + + public function isSkipped(mixed $data): bool; +} diff --git a/src/Symfony/Component/Form/Flow/FormFlowTypeInterface.php b/src/Symfony/Component/Form/Flow/FormFlowTypeInterface.php new file mode 100644 index 0000000000000..1380d4ec0b382 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlowTypeInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +use Symfony\Component\Form\FormTypeInterface; + +/** + * A type that should be converted into a {@link FormFlow} instance. + * + * @author Yonel Ceruto + */ +interface FormFlowTypeInterface extends FormTypeInterface +{ +} diff --git a/src/Symfony/Component/Form/FormFactory.php b/src/Symfony/Component/Form/FormFactory.php index dcf7b36f28d02..db6c216f1efe5 100644 --- a/src/Symfony/Component/Form/FormFactory.php +++ b/src/Symfony/Component/Form/FormFactory.php @@ -13,6 +13,7 @@ use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\Flow\FormFlowBuilderInterface; class FormFactory implements FormFactoryInterface { @@ -51,6 +52,10 @@ public function createNamedBuilder(string $name, string $type = FormType::class, $builder = $type->createBuilder($this, $name, $options); + if ($builder instanceof FormFlowBuilderInterface) { + $builder->setInitialOptions($options); + } + // Explicitly call buildForm() in order to be able to override either // createBuilder() or buildForm() in the resolved form type $type->buildForm($builder, $builder->getOptions()); diff --git a/src/Symfony/Component/Form/FormFactoryInterface.php b/src/Symfony/Component/Form/FormFactoryInterface.php index 0f311c0e57cbe..dc1048737b566 100644 --- a/src/Symfony/Component/Form/FormFactoryInterface.php +++ b/src/Symfony/Component/Form/FormFactoryInterface.php @@ -12,11 +12,17 @@ namespace Symfony\Component\Form; use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Flow\FormFlowBuilderInterface; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\Flow\FormFlowTypeInterface; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; /** * Allows creating a form based on a name, a class or a property. * + * @template TForm of ($type is class-string ? FormFlowInterface : FormInterface) + * @template TBuilder of ($type is class-string ? FormFlowBuilderInterface : FormBuilderInterface) + * * @author Bernhard Schussek */ interface FormFactoryInterface @@ -28,6 +34,8 @@ interface FormFactoryInterface * * @param mixed $data The initial data * + * @return TForm + * * @throws InvalidOptionsException if any given option is not applicable to the given type */ public function create(string $type = FormType::class, mixed $data = null, array $options = []): FormInterface; @@ -39,6 +47,8 @@ public function create(string $type = FormType::class, mixed $data = null, array * * @param mixed $data The initial data * + * @return TForm + * * @throws InvalidOptionsException if any given option is not applicable to the given type */ public function createNamed(string $name, string $type = FormType::class, mixed $data = null, array $options = []): FormInterface; @@ -52,6 +62,8 @@ public function createNamed(string $name, string $type = FormType::class, mixed * @param string $property The name of the property to guess for * @param mixed $data The initial data * + * @return TForm + * * @throws InvalidOptionsException if any given option is not applicable to the form type */ public function createForProperty(string $class, string $property, mixed $data = null, array $options = []): FormInterface; @@ -61,6 +73,8 @@ public function createForProperty(string $class, string $property, mixed $data = * * @param mixed $data The initial data * + * @return TBuilder + * * @throws InvalidOptionsException if any given option is not applicable to the given type */ public function createBuilder(string $type = FormType::class, mixed $data = null, array $options = []): FormBuilderInterface; @@ -70,6 +84,8 @@ public function createBuilder(string $type = FormType::class, mixed $data = null * * @param mixed $data The initial data * + * @return TBuilder + * * @throws InvalidOptionsException if any given option is not applicable to the given type */ public function createNamedBuilder(string $name, string $type = FormType::class, mixed $data = null, array $options = []): FormBuilderInterface; @@ -84,6 +100,8 @@ public function createNamedBuilder(string $name, string $type = FormType::class, * @param string $property The name of the property to guess for * @param mixed $data The initial data * + * @return TBuilder + * * @throws InvalidOptionsException if any given option is not applicable to the form type */ public function createBuilderForProperty(string $class, string $property, mixed $data = null, array $options = []): FormBuilderInterface; diff --git a/src/Symfony/Component/Form/ResolvedFormType.php b/src/Symfony/Component/Form/ResolvedFormType.php index 82065f651144f..e455012989b3b 100644 --- a/src/Symfony/Component/Form/ResolvedFormType.php +++ b/src/Symfony/Component/Form/ResolvedFormType.php @@ -13,6 +13,10 @@ use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Form\Exception\UnexpectedTypeException; +use Symfony\Component\Form\Flow\ActionButtonTypeInterface; +use Symfony\Component\Form\Flow\ActionButtonBuilder; +use Symfony\Component\Form\Flow\FormFlowBuilder; +use Symfony\Component\Form\Flow\FormFlowTypeInterface; use Symfony\Component\OptionsResolver\Exception\ExceptionInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -157,6 +161,14 @@ protected function newBuilder(string $name, ?string $dataClass, FormFactoryInter return new SubmitButtonBuilder($name, $options); } + if ($this->innerType instanceof ActionButtonTypeInterface) { + return new ActionButtonBuilder($name, $options); + } + + if ($this->innerType instanceof FormFlowTypeInterface) { + return new FormFlowBuilder($name, $dataClass, new EventDispatcher(), $factory, $options); + } + return new FormBuilder($name, $dataClass, new EventDispatcher(), $factory, $options); } diff --git a/src/Symfony/Component/Form/Tests/Fixtures/Flow/Data/UserSignUp.php b/src/Symfony/Component/Form/Tests/Fixtures/Flow/Data/UserSignUp.php new file mode 100644 index 0000000000000..9b14701b20552 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Fixtures/Flow/Data/UserSignUp.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Fixtures\Flow\Data; + +use Symfony\Component\Validator\Constraints as Assert; + +final class UserSignUp +{ + // personal step + #[Assert\NotBlank(groups: ['personal'])] + #[Assert\Length(min: 3, groups: ['personal'])] + public ?string $firstName = null; + public ?string $lastName = null; + public bool $worker = false; + + // professional step + #[Assert\NotBlank(groups: ['professional'])] + #[Assert\Length(min: 3, groups: ['professional'])] + public ?string $company = null; + public ?string $role = null; + + // account step + #[Assert\NotBlank(groups: ['account'])] + #[Assert\Email(groups: ['account'])] + public ?string $email = null; + #[Assert\NotBlank(groups: ['account'])] + #[Assert\PasswordStrength(groups: ['account'])] + public ?string $password = null; + + public string $currentStep = ''; +} diff --git a/src/Symfony/Component/Form/Tests/Fixtures/Flow/Extension/UserSignUpTypeExtension.php b/src/Symfony/Component/Form/Tests/Fixtures/Flow/Extension/UserSignUpTypeExtension.php new file mode 100644 index 0000000000000..1bd008b0189ea --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Fixtures/Flow/Extension/UserSignUpTypeExtension.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Fixtures\Flow\Extension; + +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Flow\FormFlowBuilderInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\Tests\Fixtures\Flow\UserSignUpType; + +class UserSignUpTypeExtension extends AbstractTypeExtension +{ + /** + * @param FormFlowBuilderInterface $builder + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addStep('first', FormType::class, ['mapped' => false], priority: 1); + $builder->addStep('last', FormType::class, ['mapped' => false]); + } + + public static function getExtendedTypes(): iterable + { + yield UserSignUpType::class; + } +} diff --git a/src/Symfony/Component/Form/Tests/Fixtures/Flow/Step/UserSignUpAccountType.php b/src/Symfony/Component/Form/Tests/Fixtures/Flow/Step/UserSignUpAccountType.php new file mode 100644 index 0000000000000..cd6f9c5a3c636 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Fixtures/Flow/Step/UserSignUpAccountType.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Fixtures\Flow\Step; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\EmailType; +use Symfony\Component\Form\Extension\Core\Type\PasswordType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class UserSignUpAccountType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('email', EmailType::class); + $builder->add('password', PasswordType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'inherit_data' => true, + ]); + } +} diff --git a/src/Symfony/Component/Form/Tests/Fixtures/Flow/Step/UserSignUpPersonalType.php b/src/Symfony/Component/Form/Tests/Fixtures/Flow/Step/UserSignUpPersonalType.php new file mode 100644 index 0000000000000..3132eb6ccaf89 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Fixtures/Flow/Step/UserSignUpPersonalType.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Fixtures\Flow\Step; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class UserSignUpPersonalType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('firstName', TextType::class); + $builder->add('lastName', TextType::class); + $builder->add('worker', CheckboxType::class, ['required' => false]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'inherit_data' => true, + ]); + } +} diff --git a/src/Symfony/Component/Form/Tests/Fixtures/Flow/Step/UserSignUpProfessionalType.php b/src/Symfony/Component/Form/Tests/Fixtures/Flow/Step/UserSignUpProfessionalType.php new file mode 100644 index 0000000000000..50f9b48b15796 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Fixtures/Flow/Step/UserSignUpProfessionalType.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Fixtures\Flow\Step; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class UserSignUpProfessionalType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('company'); + $builder->add('role', ChoiceType::class, [ + 'choices' => [ + 'Product Manager' => 'ROLE_MANAGER', + 'Developer' => 'ROLE_DEVELOPER', + 'Designer' => 'ROLE_DESIGNER', + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'inherit_data' => true, + ]); + } +} diff --git a/src/Symfony/Component/Form/Tests/Fixtures/Flow/UserSignUpNavigatorType.php b/src/Symfony/Component/Form/Tests/Fixtures/Flow/UserSignUpNavigatorType.php new file mode 100644 index 0000000000000..8adde820d4b04 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Fixtures/Flow/UserSignUpNavigatorType.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Fixtures\Flow; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\FormFlowActionType; +use Symfony\Component\Form\Extension\Core\Type\FormFlowNavigatorType; +use Symfony\Component\Form\Flow\FormFlowCursor; +use Symfony\Component\Form\FormBuilderInterface; + +class UserSignUpNavigatorType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('skip', FormFlowActionType::class, [ + 'action' => 'next', + 'clear_submission' => true, + 'include_if' => ['professional'], + ]); + + $builder->add('reset', FormFlowActionType::class, [ + 'action' => 'reset', + ]); + } + + public function getParent(): string + { + return FormFlowNavigatorType::class; + } +} diff --git a/src/Symfony/Component/Form/Tests/Fixtures/Flow/UserSignUpType.php b/src/Symfony/Component/Form/Tests/Fixtures/Flow/UserSignUpType.php new file mode 100644 index 0000000000000..d54b542d68c07 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Fixtures/Flow/UserSignUpType.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Fixtures\Flow; + +use Symfony\Component\Form\Flow\AbstractFlowType; +use Symfony\Component\Form\Flow\DataStorage\InMemoryDataStorage; +use Symfony\Component\Form\Flow\FormFlowBuilderInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\Tests\Fixtures\Flow\Data\UserSignUp; +use Symfony\Component\Form\Tests\Fixtures\Flow\Step\UserSignUpAccountType; +use Symfony\Component\Form\Tests\Fixtures\Flow\Step\UserSignUpPersonalType; +use Symfony\Component\Form\Tests\Fixtures\Flow\Step\UserSignUpProfessionalType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class UserSignUpType extends AbstractFlowType +{ + /** + * {@inheritdoc} + */ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $skip = $options['data_class'] + ? static fn (UserSignUp $data) => !$data->worker + : static fn (array $data) => !$data['worker']; + + $builder->addStep('personal', UserSignUpPersonalType::class); + $builder->addStep('professional', UserSignUpProfessionalType::class, skip: $skip); + $builder->addStep('account', UserSignUpAccountType::class); + + $builder->add('navigator', UserSignUpNavigatorType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => UserSignUp::class, + 'data_storage' => new InMemoryDataStorage('user_sign_up'), + 'step_property_path' => 'currentStep', + ]); + } +} diff --git a/src/Symfony/Component/Form/Tests/Flow/FormFlowTest.php b/src/Symfony/Component/Form/Tests/Flow/FormFlowTest.php new file mode 100644 index 0000000000000..a7ef40297b7af --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Flow/FormFlowTest.php @@ -0,0 +1,918 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Tests\Flow; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\Exception\BadMethodCallException; +use Symfony\Component\Form\Exception\RuntimeException; +use Symfony\Component\Form\Extension\Core\Type\FormFlowActionType; +use Symfony\Component\Form\Extension\Validator\ValidatorExtension; +use Symfony\Component\Form\Flow\ActionButtonInterface; +use Symfony\Component\Form\Flow\DataStorage\InMemoryDataStorage; +use Symfony\Component\Form\Flow\FormFlowCursor; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\Form\Forms; +use Symfony\Component\Form\Tests\Fixtures\Flow\Data\UserSignUp; +use Symfony\Component\Form\Tests\Fixtures\Flow\Extension\UserSignUpTypeExtension; +use Symfony\Component\Form\Tests\Fixtures\Flow\UserSignUpType; +use Symfony\Component\Validator\Mapping\Factory\LazyLoadingMetadataFactory; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; +use Symfony\Component\Validator\Validation; + +class FormFlowTest extends TestCase +{ + private FormFactoryInterface $factory; + + protected function setUp(): void + { + $validator = Validation::createValidatorBuilder() + ->setMetadataFactory(new LazyLoadingMetadataFactory(new AttributeLoader())) + ->getValidator(); + + $this->factory = Forms::createFormFactoryBuilder() + ->addExtensions([new ValidatorExtension($validator)]) + ->getFormFactory(); + } + + public function testFlowConfig() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + $config = $flow->getConfig(); + + self::assertInstanceOf(UserSignUp::class, $data = $config->getData()); + self::assertEquals(['data' => $data], $config->getInitialOptions()); + self::assertCount(3, $config->getSteps()); + self::assertTrue($config->hasStep('personal')); + self::assertTrue($config->hasStep('professional')); + self::assertTrue($config->hasStep('account')); + } + + public function testFlowCursor() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + $cursor = $flow->getCursor(); + + self::assertSame('personal', $cursor->getCurrentStep()); + self::assertTrue($cursor->isFirstStep()); + self::assertFalse($cursor->isLastStep()); + self::assertSame('personal', $cursor->getFirstStep()); + self::assertNull($cursor->getPrevStep()); + self::assertSame('professional', $cursor->getNextStep()); + self::assertSame('account', $cursor->getLastStep()); + self::assertSame(['personal', 'professional', 'account'], $cursor->getSteps()); + self::assertSame(0, $cursor->getStepIndex()); + self::assertSame(3, $cursor->getTotalSteps()); + self::assertFalse($cursor->canMoveBack()); + self::assertTrue($cursor->canMoveNext()); + + $cursor = $cursor->withCurrentStep('professional'); + + self::assertSame('professional', $cursor->getCurrentStep()); + self::assertFalse($cursor->isFirstStep()); + self::assertFalse($cursor->isLastStep()); + self::assertSame('personal', $cursor->getFirstStep()); + self::assertSame('personal', $cursor->getPrevStep()); + self::assertSame('account', $cursor->getNextStep()); + self::assertSame('account', $cursor->getLastStep()); + self::assertSame(1, $cursor->getStepIndex()); + self::assertSame(3, $cursor->getTotalSteps()); + self::assertTrue($cursor->canMoveBack()); + self::assertTrue($cursor->canMoveNext()); + + $cursor = $cursor->withCurrentStep('account'); + + self::assertSame('account', $cursor->getCurrentStep()); + self::assertFalse($cursor->isFirstStep()); + self::assertTrue($cursor->isLastStep()); + self::assertSame('personal', $cursor->getFirstStep()); + self::assertSame('professional', $cursor->getPrevStep()); + self::assertNull($cursor->getNextStep()); + self::assertSame('account', $cursor->getLastStep()); + self::assertSame(2, $cursor->getStepIndex()); + self::assertSame(3, $cursor->getTotalSteps()); + self::assertTrue($cursor->canMoveBack()); + self::assertFalse($cursor->canMoveNext()); + } + + public function testFlowViewVars() + { + $view = $this->factory->create(UserSignUpType::class, new UserSignUp()) + ->createView(); + + self::assertArrayHasKey('steps', $view->vars); + self::assertArrayHasKey('visible_steps', $view->vars); + + self::assertCount(3, $view->vars['steps']); + self::assertCount(2, $view->vars['visible_steps']); + + self::assertArrayHasKey('personal', $view->vars['steps']); + self::assertArrayHasKey('professional', $view->vars['steps']); + self::assertArrayHasKey('account', $view->vars['steps']); + self::assertArrayHasKey('personal', $view->vars['visible_steps']); + self::assertArrayHasKey('account', $view->vars['visible_steps']); + + $step1 = [ + 'name' => 'personal', + 'index' => 0, + 'position' => 1, + 'is_current_step' => true, + 'can_be_skipped' => false, + 'is_skipped' => false, + ]; + $step2 = [ + 'name' => 'professional', + 'index' => 1, + 'position' => -1, + 'is_current_step' => false, + 'can_be_skipped' => true, + 'is_skipped' => true, + ]; + $step3 = [ + 'name' => 'account', + 'index' => 2, + 'position' => 2, + 'is_current_step' => false, + 'can_be_skipped' => false, + 'is_skipped' => false, + ]; + + self::assertSame($step1, $view->vars['steps']['personal']); + self::assertSame($step2, $view->vars['steps']['professional']); + self::assertSame($step3, $view->vars['steps']['account']); + self::assertSame($step1, $view->vars['visible_steps']['personal']); + self::assertSame($step3, $view->vars['visible_steps']['account']); + } + + public function testWholeStepsFlow() + { + $data = new UserSignUp(); + $flow = $this->factory->create(UserSignUpType::class, $data); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertFalse($flow->isSubmitted()); + self::assertNull($flow->getClickedActionButton()); + self::assertTrue($flow->has('personal')); + self::assertTrue($flow->has('navigator')); + + $stepForm = $flow->get('personal'); + self::assertCount(3, $stepForm->all()); + self::assertTrue($stepForm->has('firstName')); + self::assertTrue($stepForm->has('lastName')); + self::assertTrue($stepForm->has('worker')); + + $navigatorForm = $flow->get('navigator'); + self::assertCount(2, $navigatorForm->all()); + self::assertTrue($navigatorForm->has('reset')); + self::assertTrue($navigatorForm->has('next')); + + $flow->submit([ + 'personal' => [ + 'firstName' => 'John', + 'lastName' => 'Doe', + 'worker' => '1', + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedActionButton()); + self::assertTrue($button->isNextAction()); + self::assertTrue($button->isClicked()); + + $flow = $flow->getStepForm(); + + self::assertSame('professional', $data->currentStep); + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertFalse($flow->isSubmitted()); + self::assertNull($flow->getClickedActionButton()); + self::assertTrue($flow->has('professional')); + self::assertTrue($flow->has('navigator')); + + $stepForm = $flow->get('professional'); + self::assertCount(2, $stepForm->all()); + self::assertTrue($stepForm->has('company')); + self::assertTrue($stepForm->has('role')); + + $navigatorForm = $flow->get('navigator'); + self::assertCount(4, $navigatorForm->all()); + self::assertTrue($navigatorForm->has('reset')); + self::assertTrue($navigatorForm->has('back')); + self::assertTrue($navigatorForm->has('skip')); + self::assertTrue($navigatorForm->has('next')); + + $flow->submit([ + 'professional' => [ + 'company' => 'Acme', + 'role' => 'ROLE_DEVELOPER', + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedActionButton()); + self::assertTrue($button->isNextAction()); + self::assertTrue($button->isClicked()); + + $flow = $flow->getStepForm(); + + /** @var UserSignUp $data */ + $data = $flow->getViewData(); + self::assertSame('account', $data->currentStep); + self::assertSame('account', $flow->getCursor()->getCurrentStep()); + self::assertFalse($flow->isSubmitted()); + self::assertNull($flow->getClickedActionButton()); + self::assertTrue($flow->has('account')); + self::assertTrue($flow->has('navigator')); + + $stepForm = $flow->get('account'); + self::assertCount(2, $stepForm->all()); + self::assertTrue($stepForm->has('email')); + self::assertTrue($stepForm->has('password')); + + $navigatorForm = $flow->get('navigator'); + self::assertCount(3, $navigatorForm->all()); + self::assertTrue($navigatorForm->has('reset')); + self::assertTrue($navigatorForm->has('back')); + self::assertTrue($navigatorForm->has('finish')); + + $flow->submit([ + 'account' => [ + 'email' => 'john@acme.com', + 'password' => 'eBvU2vBLfSXqf36', + ], + 'navigator' => [ + 'finish' => '', + ], + ]); + + self::assertSame('account', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertTrue($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedActionButton()); + self::assertTrue($button->isFinishAction()); + self::assertTrue($button->isClicked()); + + self::assertSame($data, $flow->getViewData()); + self::assertSame('John', $data->firstName); + self::assertSame('Doe', $data->lastName); + self::assertTrue($data->worker); + self::assertSame('Acme', $data->company); + self::assertSame('ROLE_DEVELOPER', $data->role); + self::assertSame('john@acme.com', $data->email); + self::assertSame('eBvU2vBLfSXqf36', $data->password); + } + + public function testBackActionWithPurgeSubmission() + { + $data = new UserSignUp(); + $data->firstName = 'John'; + $data->lastName = 'Doe'; + $data->worker = true; + $data->currentStep = 'professional'; + $flow = $this->factory->create(UserSignUpType::class, $data); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('professional')); + + $flow->submit([ + 'professional' => [ + 'company' => 'Acme', + 'role' => 'ROLE_DEVELOPER', + ], + 'navigator' => [ + 'back' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedActionButton()); + self::assertTrue($button->isBackAction()); + + $flow = $flow->getStepForm(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal'), 'back action should move the flow one step back'); + self::assertNull($data->company, 'pro step should be silenced on submit'); + self::assertNull($data->role, 'pro step should be silenced on submit'); + } + + public function testBackActionWithoutPurgeSubmission() + { + $data = new UserSignUp(); + $data->firstName = 'John'; + $data->lastName = 'Doe'; + $data->worker = true; + $data->currentStep = 'professional'; + + $flow = $this->factory->create(UserSignUpType::class, $data); + // back action without purge submission + $flow->get('navigator')->add('back', FormFlowActionType::class, [ + 'action' => 'back', + 'validate' => false, + 'validation_groups' => false, + 'clear_submission' => false, + 'include_if' => fn (FormFlowCursor $cursor) => $cursor->canMoveBack(), + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('professional')); + + $flow->submit([ + 'professional' => [ + 'company' => 'Acme', + 'role' => 'ROLE_DEVELOPER', + ], + 'navigator' => [ + 'back' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedActionButton()); + self::assertTrue($button->isBackAction()); + + $flow = $flow->getStepForm(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal'), 'back action should move the flow one step back'); + self::assertSame('Acme', $data->company, 'pro step should NOT be silenced on submit'); + self::assertSame('ROLE_DEVELOPER', $data->role, 'pro step should NOT be silenced on submit'); + } + + public function testSkipStepBasedOnData() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal')); + + $flow->submit([ + 'personal' => [ + 'firstName' => 'John', + 'lastName' => 'Doe', + // worker checkbox was not clicked + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedActionButton()); + self::assertTrue($button->isNextAction()); + + $flow = $flow->getStepForm(); + + self::assertFalse($flow->has('professional'), 'pro step should be skipped'); + self::assertSame('account', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('account')); + } + + public function testResetAction() + { + $data = new UserSignUp(); + $data->firstName = 'John'; + $data->lastName = 'Doe'; + $data->worker = true; + $data->currentStep = 'professional'; + + $dataStorage = new InMemoryDataStorage('user_sign_up'); + $dataStorage->save($data); + + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp(), [ + 'data_storage' => $dataStorage, + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('professional')); + + $flow->submit([ + 'professional' => [ + 'company' => 'Acme', + 'role' => 'ROLE_DEVELOPER', + ], + 'navigator' => [ + 'reset' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedActionButton()); + self::assertTrue($button->isResetAction()); + + $flow = $flow->getStepForm(); + /** @var UserSignUp $data */ + $data = $flow->getViewData(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal'), 'reset action should move the flow to the initial step'); + self::assertNull($data->firstName); + self::assertNull($data->lastName); + self::assertFalse($data->worker); + self::assertNull($data->company); + self::assertNull($data->role); + } + + public function testResetManually() + { + $data = new UserSignUp(); + $data->firstName = 'John'; + $data->lastName = 'Doe'; + $data->worker = true; + $data->currentStep = 'professional'; + + $dataStorage = new InMemoryDataStorage('user_sign_up'); + $dataStorage->save($data); + + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp(), [ + 'data_storage' => $dataStorage, + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + + $flow->reset(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + } + + public function testSkipAction() + { + $data = new UserSignUp(); + $data->firstName = 'John'; + $data->lastName = 'Doe'; + $data->worker = true; + $data->currentStep = 'professional'; + + $dataStorage = new InMemoryDataStorage('user_sign_up'); + $dataStorage->save($data); + + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp(), [ + 'data_storage' => $dataStorage, + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('professional')); + + $flow->submit([ + 'professional' => [ + 'company' => 'Acme', + 'role' => 'ROLE_DEVELOPER', + ], + 'navigator' => [ + 'skip' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedActionButton()); + self::assertTrue($button->isNextAction()); + self::assertSame('skip', $button->getName()); + + $flow = $flow->getStepForm(); + /** @var UserSignUp $data */ + $data = $flow->getViewData(); + + self::assertSame('account', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('account'), 'skip action should move the flow to the next step but skip submitted data and clear'); + self::assertSame('John', $data->firstName); + self::assertSame('Doe', $data->lastName); + self::assertTrue($data->worker); + self::assertNull($data->company); + self::assertNull($data->role); + } + + public function testTypeExtensionAndStepsPriority() + { + $factory = Forms::createFormFactoryBuilder() + ->addTypeExtension(new UserSignUpTypeExtension()) + ->getFormFactory(); + + $flow = $factory->create(UserSignUpType::class, new UserSignUp()); + + self::assertSame('first', $flow->getCursor()->getCurrentStep()); + self::assertSame(['first', 'personal', 'professional', 'account', 'last'], $flow->getCursor()->getSteps()); + } + + public function testMoveBackToStep() + { + $data = new UserSignUp(); + $data->firstName = 'John'; + $data->lastName = 'Doe'; + $data->worker = true; + $data->company = 'Acme'; + $data->role = 'ROLE_DEVELOPER'; + $data->currentStep = 'account'; + + $flow = $this->factory->create(UserSignUpType::class, $data); + $flow->get('navigator')->add('back_to_step', FormFlowActionType::class, [ + 'action' => 'back', + 'validate' => false, + 'validation_groups' => false, + 'clear_submission' => false, + ]); + + self::assertSame('account', $flow->getCursor()->getCurrentStep()); + + $flow->submit([ + 'account' => [ + 'email' => 'jdoe@acme.com', + 'password' => '$ecret', + ], + 'navigator' => [ + 'back_to_step' => 'personal', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedActionButton()); + self::assertTrue($button->isBackAction()); + self::assertSame('personal', $button->getViewData()); + + $flow = $flow->getStepForm(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal')); + self::assertSame('John', $data->firstName); + self::assertSame('Acme', $data->company); + self::assertSame('jdoe@acme.com', $data->email); + } + + public function testMoveManually() + { + $data = new UserSignUp(); + $data->firstName = 'John'; + $data->lastName = 'Doe'; + $data->worker = true; + $data->currentStep = 'professional'; + + $dataStorage = new InMemoryDataStorage('user_sign_up'); + $dataStorage->save($data); + + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp(), [ + 'data_storage' => $dataStorage, + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('professional')); + + $flow->moveBack(); + $flow = $flow->createStepForm(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal')); + + $flow->moveNext(); + $flow = $flow->createStepForm(); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('professional')); + } + + public function testInvalidMoveBackUntilAheadStep() + { + $data = new UserSignUp(); + $data->currentStep = 'personal'; + $flow = $this->factory->create(UserSignUpType::class, $data); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot move back to step "account" because it is ahead of the current step "personal".'); + + $flow->moveBack('account'); + } + + public function testInvalidMoveBackUntilSkippedStep() + { + $data = new UserSignUp(); + $data->worker = false; + $data->currentStep = 'account'; + $flow = $this->factory->create(UserSignUpType::class, $data); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot move back to step "professional" because it is a skipped step.'); + + $flow->moveBack('professional'); + } + + public function testInvalidStepForm() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal')); + + $flow->submit([ + 'personal' => [ + 'firstName' => '', // This value should not be blank + 'lastName' => 'Doe', + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertFalse($flow->isValid()); + self::assertNotNull($button = $flow->getClickedActionButton()); + self::assertTrue($button->isNextAction()); + self::assertSame($flow, $flow->getStepForm()); + self::assertSame('This value should not be blank.', $flow->getErrors(true)->current()->getMessage()); + } + + public function testCannotModifyStepConfigAfterFormBuilding() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('FormFlowStepBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowStepConfigInterface instance.'); + + $flow->getConfig()->getStep('personal')->setPriority(0); + } + + public function testIgnoreSubmissionIfStepIsMissing() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal')); + + $flow->submit([ + 'account' => [ + 'firstName' => '', + 'lastName' => '', + ], + 'navigator' => [ + 'back' => '', + ], + ]); + + self::assertFalse($flow->isSubmitted()); + } + + public function testViewVars() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + $view = $flow->createView(); + + self::assertInstanceOf(FormFlowCursor::class, $view->vars['cursor']); + self::assertCount(3, $view->vars['steps']); + self::assertSame(['personal', 'professional', 'account'], array_keys($view->vars['steps'])); + self::assertSame('personal', $view->vars['steps']['personal']['name']); + self::assertTrue($view->vars['steps']['personal']['is_current_step']); + self::assertFalse($view->vars['steps']['personal']['is_skipped']); + self::assertSame('professional', $view->vars['steps']['professional']['name']); + self::assertFalse($view->vars['steps']['professional']['is_current_step']); + self::assertTrue($view->vars['steps']['professional']['is_skipped']); + self::assertSame('account', $view->vars['steps']['account']['name']); + self::assertFalse($view->vars['steps']['account']['is_current_step']); + self::assertFalse($view->vars['steps']['account']['is_skipped']); + } + + public function testFallbackCurrentStep() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + + /** @var UserSignUp $data */ + $data = $flow->getViewData(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep(), 'The current step should be the first one depending on the step priority'); + self::assertSame('personal', $data->currentStep); + } + + public function testInitialCurrentStep() + { + $data = new UserSignUp(); + $data->currentStep = 'professional'; + $flow = $this->factory->create(UserSignUpType::class, $data); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep(), 'The current step should be the one set in the initial data'); + self::assertSame('professional', $data->currentStep); + } + + public function testFormFlowWithArrayData() + { + $flow = $this->factory->create(UserSignUpType::class, [], [ + 'data_class' => null, + 'step_property_path' => '[currentStep]', + ]); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertFalse($flow->isSubmitted()); + self::assertNull($flow->getClickedActionButton()); + self::assertTrue($flow->has('personal')); + self::assertTrue($flow->has('navigator')); + + $stepForm = $flow->get('personal'); + self::assertCount(3, $stepForm->all()); + self::assertTrue($stepForm->has('firstName')); + self::assertTrue($stepForm->has('lastName')); + self::assertTrue($stepForm->has('worker')); + + $navigatorForm = $flow->get('navigator'); + self::assertCount(2, $navigatorForm->all()); + self::assertTrue($navigatorForm->has('reset')); + self::assertTrue($navigatorForm->has('next')); + + $flow->submit([ + 'personal' => [ + 'firstName' => 'John', + 'lastName' => 'Doe', + 'worker' => '1', + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedActionButton()); + self::assertTrue($button->isNextAction()); + self::assertTrue($button->isClicked()); + + $flow = $flow->getStepForm(); + + $data = $flow->getData(); + self::assertSame('professional', $data['currentStep']); + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertFalse($flow->isSubmitted()); + self::assertNull($flow->getClickedActionButton()); + self::assertTrue($flow->has('professional')); + self::assertTrue($flow->has('navigator')); + + $stepForm = $flow->get('professional'); + self::assertCount(2, $stepForm->all()); + self::assertTrue($stepForm->has('company')); + self::assertTrue($stepForm->has('role')); + + $navigatorForm = $flow->get('navigator'); + self::assertCount(4, $navigatorForm->all()); + self::assertTrue($navigatorForm->has('reset')); + self::assertTrue($navigatorForm->has('back')); + self::assertTrue($navigatorForm->has('skip')); + self::assertTrue($navigatorForm->has('next')); + + $flow->submit([ + 'professional' => [ + 'company' => 'Acme', + 'role' => 'ROLE_DEVELOPER', + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedActionButton()); + self::assertTrue($button->isNextAction()); + self::assertTrue($button->isClicked()); + + $flow = $flow->getStepForm(); + + $data = $flow->getData(); + self::assertSame('account', $data['currentStep']); + self::assertSame('account', $flow->getCursor()->getCurrentStep()); + self::assertFalse($flow->isSubmitted()); + self::assertNull($flow->getClickedActionButton()); + self::assertTrue($flow->has('account')); + self::assertTrue($flow->has('navigator')); + + $stepForm = $flow->get('account'); + self::assertCount(2, $stepForm->all()); + self::assertTrue($stepForm->has('email')); + self::assertTrue($stepForm->has('password')); + + $navigatorForm = $flow->get('navigator'); + self::assertCount(3, $navigatorForm->all()); + self::assertTrue($navigatorForm->has('reset')); + self::assertTrue($navigatorForm->has('back')); + self::assertTrue($navigatorForm->has('finish')); + + $flow->submit([ + 'account' => [ + 'email' => 'john@acme.com', + 'password' => 'eBvU2vBLfSXqf36', + ], + 'navigator' => [ + 'finish' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertTrue($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedActionButton()); + self::assertTrue($button->isFinishAction()); + self::assertTrue($button->isClicked()); + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + + $data = $flow->getData(); + self::assertSame('John', $data['firstName']); + self::assertSame('Doe', $data['lastName']); + self::assertTrue($data['worker']); + self::assertSame('Acme', $data['company']); + self::assertSame('ROLE_DEVELOPER', $data['role']); + self::assertSame('john@acme.com', $data['email']); + self::assertSame('eBvU2vBLfSXqf36', $data['password']); + } + + public function testHandleActionManually() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + + $flow->submit([ + 'personal' => [ + 'firstName' => 'John', + 'lastName' => 'Doe', + 'worker' => '1', + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + + $flow->handleAction(); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + } + + public function testAddFormErrorOnActionHandling() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + $flow->get('navigator')->add('next', FormFlowActionType::class, [ + 'action' => 'next', + 'handler' => function (mixed $data, ActionButtonInterface $button, FormFlowInterface $flow) { + $flow->addError(new FormError('Action error')); + }, + ]); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + + $flow->submit([ + 'personal' => [ + 'firstName' => 'John', + 'lastName' => 'Doe', + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + + $flow->handleAction(); + $flow = $flow->getStepForm(); + $errors = $flow->getErrors(true); + + self::assertFalse($flow->isValid()); + self::assertCount(1, $errors); + self::assertSame('Action error', $errors->current()->getMessage()); + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + } +}