From 92ee39c3c626ce2d97c0b179cbbc95893d6ae49c 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 --- .../FrameworkBundle/Resources/config/form.php | 5 + .../Bundle/FrameworkBundle/composer.json | 4 +- src/Symfony/Component/Form/CHANGELOG.md | 1 + .../Form/Extension/Core/CoreExtension.php | 7 + .../HttpFoundationExtension.php | 1 + ...ormFlowTypeSessionDataStorageExtension.php | 44 + .../Component/Form/Flow/AbstractFlowType.php | 38 + .../Flow/DataStorage/DataStorageInterface.php | 26 + .../Flow/DataStorage/InMemoryDataStorage.php | 40 + .../Form/Flow/DataStorage/NullDataStorage.php | 33 + .../Flow/DataStorage/SessionDataStorage.php | 41 + .../Component/Form/Flow/FlowButton.php | 92 ++ .../Component/Form/Flow/FlowButtonBuilder.php | 27 + .../Form/Flow/FlowButtonInterface.php | 56 ++ .../Form/Flow/FlowButtonTypeInterface.php | 23 + .../Component/Form/Flow/FlowCursor.php | 103 ++ .../Component/Form/Flow/FlowStepBuilder.php | 112 +++ .../Form/Flow/FlowStepBuilderInterface.php | 48 + .../Form/Flow/FlowStepConfigInterface.php | 33 + src/Symfony/Component/Form/Flow/FormFlow.php | 243 +++++ .../Component/Form/Flow/FormFlowBuilder.php | 255 +++++ .../Form/Flow/FormFlowBuilderInterface.php | 74 ++ .../Form/Flow/FormFlowConfigInterface.php | 68 ++ .../Component/Form/Flow/FormFlowInterface.php | 74 ++ .../Form/Flow/FormFlowTypeInterface.php | 24 + .../StepAccessor/PropertyPathStepAccessor.php | 37 + .../StepAccessor/StepAccessorInterface.php | 24 + .../Form/Flow/Type/FlowButtonType.php | 65 ++ .../Form/Flow/Type/FlowFinishType.php | 41 + .../Form/Flow/Type/FlowNavigatorType.php | 40 + .../Component/Form/Flow/Type/FlowNextType.php | 41 + .../Form/Flow/Type/FlowPreviousType.php | 42 + .../Form/Flow/Type/FlowResetType.php | 40 + .../Component/Form/Flow/Type/FormFlowType.php | 132 +++ src/Symfony/Component/Form/FormFactory.php | 19 + .../Component/Form/FormFactoryInterface.php | 11 + .../Component/Form/ResolvedFormType.php | 13 +- .../Component/Form/Test/FormInterface.php | 3 + .../Tests/Fixtures/Flow/Data/UserSignUp.php | 40 + .../Extension/UserSignUpTypeExtension.php | 34 + .../Flow/Step/UserSignUpAccountType.php | 34 + .../Flow/Step/UserSignUpPersonalType.php | 35 + .../Flow/Step/UserSignUpProfessionalType.php | 39 + .../Fixtures/Flow/UserSignUpNavigatorType.php | 36 + .../Tests/Fixtures/Flow/UserSignUpType.php | 46 + .../Form/Tests/Flow/FormFlowBuilderTest.php | 92 ++ .../Form/Tests/Flow/FormFlowCursorTest.php | 202 ++++ .../Form/Tests/Flow/FormFlowTest.php | 935 ++++++++++++++++++ 48 files changed, 3470 insertions(+), 3 deletions(-) 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/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/FlowButton.php create mode 100644 src/Symfony/Component/Form/Flow/FlowButtonBuilder.php create mode 100644 src/Symfony/Component/Form/Flow/FlowButtonInterface.php create mode 100644 src/Symfony/Component/Form/Flow/FlowButtonTypeInterface.php create mode 100644 src/Symfony/Component/Form/Flow/FlowCursor.php create mode 100644 src/Symfony/Component/Form/Flow/FlowStepBuilder.php create mode 100644 src/Symfony/Component/Form/Flow/FlowStepBuilderInterface.php create mode 100644 src/Symfony/Component/Form/Flow/FlowStepConfigInterface.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/FormFlowInterface.php create mode 100644 src/Symfony/Component/Form/Flow/FormFlowTypeInterface.php create mode 100644 src/Symfony/Component/Form/Flow/StepAccessor/PropertyPathStepAccessor.php create mode 100644 src/Symfony/Component/Form/Flow/StepAccessor/StepAccessorInterface.php create mode 100644 src/Symfony/Component/Form/Flow/Type/FlowButtonType.php create mode 100644 src/Symfony/Component/Form/Flow/Type/FlowFinishType.php create mode 100644 src/Symfony/Component/Form/Flow/Type/FlowNavigatorType.php create mode 100644 src/Symfony/Component/Form/Flow/Type/FlowNextType.php create mode 100644 src/Symfony/Component/Form/Flow/Type/FlowPreviousType.php create mode 100644 src/Symfony/Component/Form/Flow/Type/FlowResetType.php create mode 100644 src/Symfony/Component/Form/Flow/Type/FormFlowType.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/FormFlowBuilderTest.php create mode 100644 src/Symfony/Component/Form/Tests/Flow/FormFlowCursorTest.php create mode 100644 src/Symfony/Component/Form/Tests/Flow/FormFlowTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/form.php index 3c936a284b32..e83457744dd9 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 0c3dfd4c462d..9337212e7dbb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -46,7 +46,7 @@ "symfony/dom-crawler": "^6.4|^7.0|^8.0", "symfony/dotenv": "^6.4|^7.0|^8.0", "symfony/polyfill-intl-icu": "~1.0", - "symfony/form": "^6.4|^7.0|^8.0", + "symfony/form": "^7.4|^8.0", "symfony/expression-language": "^6.4|^7.0|^8.0", "symfony/html-sanitizer": "^6.4|^7.0|^8.0", "symfony/http-client": "^6.4|^7.0|^8.0", @@ -89,7 +89,7 @@ "symfony/dotenv": "<6.4", "symfony/dom-crawler": "<6.4", "symfony/http-client": "<6.4", - "symfony/form": "<6.4", + "symfony/form": "<7.4", "symfony/lock": "<6.4", "symfony/mailer": "<6.4", "symfony/messenger": "<6.4", diff --git a/src/Symfony/Component/Form/CHANGELOG.md b/src/Symfony/Component/Form/CHANGELOG.md index b74d43e79d23..3a087a47c8b3 100644 --- a/src/Symfony/Component/Form/CHANGELOG.md +++ b/src/Symfony/Component/Form/CHANGELOG.md @@ -5,6 +5,7 @@ CHANGELOG --- * Add `input=date_point` to `DateTimeType`, `DateType` and `TimeType` + * Add `FormFlow` 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 1640ed05246a..da76e1cd54db 100644 --- a/src/Symfony/Component/Form/Extension/Core/CoreExtension.php +++ b/src/Symfony/Component/Form/Extension/Core/CoreExtension.php @@ -17,6 +17,7 @@ use Symfony\Component\Form\ChoiceList\Factory\DefaultChoiceListFactory; use Symfony\Component\Form\ChoiceList\Factory\PropertyAccessDecorator; use Symfony\Component\Form\Extension\Core\Type\TransformationFailureExtension; +use Symfony\Component\Form\Flow; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -78,6 +79,12 @@ protected function loadTypes(): array new Type\TelType(), new Type\ColorType($this->translator), new Type\WeekType(), + new Flow\Type\FlowButtonType(), + new Flow\Type\FlowFinishType(), + new Flow\Type\FlowNavigatorType(), + new Flow\Type\FlowNextType(), + new Flow\Type\FlowPreviousType(), + new Flow\Type\FormFlowType($this->propertyAccessor), ]; } diff --git a/src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationExtension.php b/src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationExtension.php index 85bc4f4720b3..0a17bf216afb 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 000000000000..fe5c730beb73 --- /dev/null +++ b/src/Symfony/Component/Form/Extension/HttpFoundation/Type/FormFlowTypeSessionDataStorageExtension.php @@ -0,0 +1,44 @@ + + * + * 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\Flow\DataStorage\SessionDataStorage; +use Symfony\Component\Form\Flow\FormFlowBuilderInterface; +use Symfony\Component\Form\Flow\Type\FormFlowType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\HttpFoundation\RequestStack; + +class FormFlowTypeSessionDataStorageExtension extends AbstractTypeExtension +{ + public function __construct( + private readonly ?RequestStack $requestStack = null, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + \assert($builder instanceof FormFlowBuilderInterface); + + 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 + { + return [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 000000000000..62e2fdba84b0 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/AbstractFlowType.php @@ -0,0 +1,38 @@ + + * + * 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\Flow\Type\FormFlowType; +use Symfony\Component\Form\FormBuilderInterface; + +/** + * @author Yonel Ceruto + */ +abstract class AbstractFlowType extends AbstractType implements FormFlowTypeInterface +{ + final public function buildForm(FormBuilderInterface $builder, array $options): void + { + \assert($builder instanceof FormFlowBuilderInterface); + + $this->buildFormFlow($builder, $options); + } + + public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void + { + } + + public function getParent(): string + { + return FormFlowType::class; + } +} 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 000000000000..5b9f188a79ad --- /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 000000000000..b1eb12f51847 --- /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 000000000000..09ad2f78e005 --- /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 000000000000..44f4445b6497 --- /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/FlowButton.php b/src/Symfony/Component/Form/Flow/FlowButton.php new file mode 100644 index 000000000000..67b4a4395116 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FlowButton.php @@ -0,0 +1,92 @@ + + * + * 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; +use Symfony\Component\Form\SubmitButton; + +/** + * A button that submits the form and handles an action. + * + * @author Yonel Ceruto + */ +class FlowButton extends SubmitButton implements FlowButtonInterface +{ + private mixed $data = null; + private bool $handled = false; + + 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 handle(): void + { + /** @var FormInterface $form */ + $form = $this->getParent(); + $data = $form->getData(); + + while ($form && !$form instanceof FormFlowInterface) { + $form = $form->getParent(); + } + + $handler = $this->getConfig()->getOption('handler'); + $handler($data, $this, $form); + + $this->handled = true; + } + + public function isHandled(): bool + { + return $this->handled; + } + + public function isResetAction(): bool + { + return 'reset' === $this->getConfig()->getAttribute('action'); + } + + public function isPreviousAction(): bool + { + return 'previous' === $this->getConfig()->getAttribute('action'); + } + + public function isNextAction(): bool + { + return 'next' === $this->getConfig()->getAttribute('action'); + } + + public function isFinishAction(): bool + { + return 'finish' === $this->getConfig()->getAttribute('action'); + } + + public function isClearSubmission(): bool + { + return $this->getConfig()->getOption('clear_submission'); + } +} diff --git a/src/Symfony/Component/Form/Flow/FlowButtonBuilder.php b/src/Symfony/Component/Form/Flow/FlowButtonBuilder.php new file mode 100644 index 000000000000..7a22fef1f8e7 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FlowButtonBuilder.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 FlowButton} instances. + * + * @author Yonel Ceruto + */ +class FlowButtonBuilder extends ButtonBuilder +{ + public function getForm(): FlowButton + { + return new FlowButton($this->getFormConfig()); + } +} diff --git a/src/Symfony/Component/Form/Flow/FlowButtonInterface.php b/src/Symfony/Component/Form/Flow/FlowButtonInterface.php new file mode 100644 index 000000000000..22b5cfcc674a --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FlowButtonInterface.php @@ -0,0 +1,56 @@ + + * + * 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 FlowButtonInterface extends FormInterface, ClickableInterface +{ + /** + * Executes the callable handler. + */ + public function handle(): void; + + /** + * Checks if the callable handler was already called. + */ + public function isHandled(): bool; + + /** + * Checks if the button's action is 'reset'. + */ + public function isResetAction(): bool; + + /** + * Checks if the button's action is 'previous'. + */ + public function isPreviousAction(): bool; + + /** + * Checks if the button's action is 'next'. + */ + public function isNextAction(): bool; + + /** + * Checks if the button's action is 'finish'. + */ + public function isFinishAction(): bool; + + /** + * Checks if the button is configured to clear submission data. + */ + public function isClearSubmission(): bool; +} diff --git a/src/Symfony/Component/Form/Flow/FlowButtonTypeInterface.php b/src/Symfony/Component/Form/Flow/FlowButtonTypeInterface.php new file mode 100644 index 000000000000..c7cb96312ee8 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FlowButtonTypeInterface.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 FlowButton} instance. + * + * @author Yonel Ceruto + */ +interface FlowButtonTypeInterface extends FormTypeInterface +{ +} diff --git a/src/Symfony/Component/Form/Flow/FlowCursor.php b/src/Symfony/Component/Form/Flow/FlowCursor.php new file mode 100644 index 000000000000..15f5b9e664ff --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FlowCursor.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 FlowCursor +{ + /** + * @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('", "', $steps))); + } + } + + public function getSteps(): array + { + return $this->steps; + } + + public function getTotalSteps(): int + { + return \count($this->steps); + } + + public function getStepIndex(): int + { + return (int) array_search($this->currentStep, $this->steps, true); + } + + public function getFirstStep(): string + { + return $this->steps[0]; + } + + public function getPreviousStep(): ?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->getPreviousStep(); + } + + public function canMoveNext(): bool + { + return null !== $this->getNextStep(); + } +} diff --git a/src/Symfony/Component/Form/Flow/FlowStepBuilder.php b/src/Symfony/Component/Form/Flow/FlowStepBuilder.php new file mode 100644 index 000000000000..d20f2feccd8b --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FlowStepBuilder.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 FlowStepBuilder implements FlowStepBuilderInterface +{ + 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('FlowStepBuilder methods cannot be accessed anymore once the builder is turned into a FlowStepConfigInterface instance.'); + } + + return $this->type; + } + + public function getOptions(): array + { + if ($this->locked) { + throw new BadMethodCallException('FlowStepBuilder methods cannot be accessed anymore once the builder is turned into a FlowStepConfigInterface instance.'); + } + + return $this->options; + } + + public function getPriority(): int + { + return $this->priority; + } + + public function setPriority(int $priority): static + { + if ($this->locked) { + throw new BadMethodCallException('FlowStepBuilder methods cannot be accessed anymore once the builder is turned into a FlowStepConfigInterface 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('FlowStepBuilder methods cannot be accessed anymore once the builder is turned into a FlowStepConfigInterface instance.'); + } + + $this->skip = $skip; + + return $this; + } + + public function getStepConfig(): FlowStepConfigInterface + { + if ($this->locked) { + throw new BadMethodCallException('FlowStepBuilder methods cannot be accessed anymore once the builder is turned into a FlowStepConfigInterface 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/FlowStepBuilderInterface.php b/src/Symfony/Component/Form/Flow/FlowStepBuilderInterface.php new file mode 100644 index 000000000000..76f519874fea --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FlowStepBuilderInterface.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\Flow; + +/** + * @author Yonel Ceruto + */ +interface FlowStepBuilderInterface extends FlowStepConfigInterface +{ + /** + * Returns the form type class name for the step. + */ + public function getType(): string; + + /** + * Returns the form options for the step. + */ + public function getOptions(): array; + + /** + * Returns the priority of the step. + */ + public function getPriority(): int; + + /** + * Sets the priority of the step. + */ + public function setPriority(int $priority): static; + + /** + * Sets the closure that determines if the step should be skipped. + */ + public function setSkip(?\Closure $skip): static; + + /** + * Returns a FlowStepConfigInterface instance for the step. + */ + public function getStepConfig(): FlowStepConfigInterface; +} diff --git a/src/Symfony/Component/Form/Flow/FlowStepConfigInterface.php b/src/Symfony/Component/Form/Flow/FlowStepConfigInterface.php new file mode 100644 index 000000000000..03c98d0e3162 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FlowStepConfigInterface.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; + +/** + * @author Yonel Ceruto + */ +interface FlowStepConfigInterface +{ + /** + * Returns the name of the step. + */ + public function getName(): string; + + /** + * Returns the closure that determines if the step should be skipped. + */ + public function getSkip(): ?\Closure; + + /** + * Determines if the step should be skipped based on the provided data. + */ + public function isSkipped(mixed $data): bool; +} diff --git a/src/Symfony/Component/Form/Flow/FormFlow.php b/src/Symfony/Component/Form/Flow/FormFlow.php new file mode 100644 index 000000000000..9619ad71afaf --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlow.php @@ -0,0 +1,243 @@ + + * + * 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\Exception\AlreadySubmittedException; +use Symfony\Component\Form\Exception\InvalidArgumentException; +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 ?FlowButtonInterface $clickedFlowButton = null; + private bool $finished = false; + + public function __construct( + private readonly FormFlowConfigInterface $config, + private FlowCursor $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->setClickedFlowButton($submittedData, $this); + + parent::submit($submittedData, $clearMissing); + + if (!$this->clickedFlowButton || !$this->isSubmitted() || !$this->isValid()) { + return $this; + } + + $this->finished = $this->clickedFlowButton->isFinishAction(); + + if ($this->finished && $this->config->isAutoReset()) { + $this->reset(); + } + + return $this; + } + + public function reset(): void + { + $this->config->getDataStorage()->clear(); + $this->cursor = $this->cursor->withCurrentStep($this->config->getInitialStep()); + } + + public function movePrevious(?string $step = null): void + { + if ($step) { + $this->moveBackTo($step); + + return; + } + + if (!$this->move(fn (FlowCursor $cursor) => $cursor->getPreviousStep())) { + throw new RuntimeException('Cannot determine previous step.'); + } + } + + public function moveNext(): void + { + if (!$this->move(fn (FlowCursor $cursor) => $cursor->getNextStep())) { + throw new RuntimeException('Cannot determine next step.'); + } + } + + public function newStepForm(): 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->clickedFlowButton && !$this->clickedFlowButton->isHandled()) { + $this->clickedFlowButton->handle(); + } + + if (!$this->isValid()) { + return $this; + } + + return $this->newStepForm(); + } + + public function getCursor(): FlowCursor + { + return $this->cursor; + } + + public function getConfig(): FormFlowConfigInterface + { + return $this->config; + } + + public function isFinished(): bool + { + return $this->finished; + } + + public function getClickedButton(): FlowButtonInterface|FormInterface|ClickableInterface|null + { + return parent::getClickedButton() ?? $this->clickedFlowButton; + } + + private function setClickedFlowButton(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->setClickedFlowButton($submittedData[$name], $child); + + if ($this->clickedFlowButton) { + return; + } + + continue; + } + + if (!$child instanceof FlowButtonInterface) { + continue; + } + + $child->submit($submittedData[$name]); + + if ($child->isClicked()) { + $this->clickedFlowButton = $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->movePrevious(); + $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; + + while (true) { + if (null === $newStep = $direction($cursor)) { + return false; + } + + if ($cursor->getCurrentStep() === $newStep) { + return 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 000000000000..b42610381fc9 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlowBuilder.php @@ -0,0 +1,255 @@ + + * + * 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\DataStorage\DataStorageInterface; +use Symfony\Component\Form\Flow\StepAccessor\StepAccessorInterface; +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 = []): FlowStepBuilderInterface + { + if ($this->locked) { + throw new BadMethodCallException('FormFlowBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowConfigInterface instance.'); + } + + return new FlowStepBuilder($name, $type, $options); + } + + public function addStep(FlowStepBuilderInterface|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 FlowStepBuilderInterface) { + $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): FlowStepBuilderInterface + { + 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 + { + $defaultStep = (string) key($this->steps); + + if (!isset($this->initialOptions['data'])) { + return $defaultStep; + } + + return (string) $this->stepAccessor->getStep($this->initialOptions['data'], $defaultStep); + } + + 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 (FlowStepBuilderInterface $a, FlowStepBuilderInterface $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 FlowCursor(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, FlowCursor $cursor): void + { + foreach ($builder->all() as $child) { + if ($child->count() > 0) { + $this->pruneActionButtons($child, $cursor); + + continue; + } + + if (!$child instanceof FlowButtonBuilder || !\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 000000000000..5ab4c7e21449 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlowBuilderInterface.php @@ -0,0 +1,74 @@ + + * + * 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\DataStorage\DataStorageInterface; +use Symfony\Component\Form\Flow\StepAccessor\StepAccessorInterface; +use Symfony\Component\Form\FormBuilderInterface; + +/** + * @author Yonel Ceruto + * + * @extends \Traversable + */ +interface FormFlowBuilderInterface extends FormBuilderInterface, FormFlowConfigInterface +{ + /** + * Creates a new step builder. + */ + public function createStep(string $name, string $type = FormType::class, array $options = []): FlowStepBuilderInterface; + + /** + * Adds a step to the form flow. + */ + public function addStep(FlowStepBuilderInterface|string $name, string $type = FormType::class, array $options = [], ?callable $skip = null, int $priority = 0): static; + + /** + * Removes a step from the form flow. + */ + public function removeStep(string $name): static; + + /** + * Returns a step builder by name. + */ + public function getStep(string $name): FlowStepBuilderInterface; + + /** + * Returns all step builders. + * + * @return array + */ + public function getSteps(): array; + + /** + * Sets the initial options for the form flow. + * + * @param array $options + */ + public function setInitialOptions(array $options): static; + + /** + * Sets the data storage for the form flow. + */ + public function setDataStorage(DataStorageInterface $dataStorage): static; + + /** + * Sets the step accessor for the form flow. + */ + public function setStepAccessor(StepAccessorInterface $stepAccessor): static; + + /** + * Creates and returns the form flow instance. + */ + 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 000000000000..93d3cdbdc88a --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlowConfigInterface.php @@ -0,0 +1,68 @@ + + * + * 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\DataStorage\DataStorageInterface; +use Symfony\Component\Form\Flow\StepAccessor\StepAccessorInterface; +use Symfony\Component\Form\FormConfigInterface; + +/** + * The configuration of a {@link FormFlow} object. + * + * @author Yonel Ceruto + */ +interface FormFlowConfigInterface extends FormConfigInterface +{ + /** + * Checks if a step with the given name exists. + */ + public function hasStep(string $name): bool; + + /** + * Returns the step with the given name. + */ + public function getStep(string $name): FlowStepConfigInterface; + + /** + * Returns all steps. + * + * @return array + */ + public function getSteps(): array; + + /** + * Returns the name of the initial step. + */ + public function getInitialStep(): string; + + /** + * Returns the initial options for the form flow. + * + * @return array + */ + public function getInitialOptions(): array; + + /** + * Returns the data storage for the form flow. + */ + public function getDataStorage(): DataStorageInterface; + + /** + * Returns the step accessor for the form flow. + */ + public function getStepAccessor(): StepAccessorInterface; + + /** + * Checks if the form flow is configured to auto reset once it's finished. + */ + public function isAutoReset(): bool; +} diff --git a/src/Symfony/Component/Form/Flow/FormFlowInterface.php b/src/Symfony/Component/Form/Flow/FormFlowInterface.php new file mode 100644 index 000000000000..eb4827ce9eb6 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlowInterface.php @@ -0,0 +1,74 @@ + + * + * 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\Exception\RuntimeException; +use Symfony\Component\Form\FormInterface; + +/** + * @author Yonel Ceruto + */ +interface FormFlowInterface extends FormInterface +{ + /** + * Returns the button used to submit the form. + */ + public function getClickedButton(): FlowButtonInterface|FormInterface|ClickableInterface|null; + + /** + * Resets the flow by clearing stored data and setting the cursor to the initial step. + */ + public function reset(): void; + + /** + * Moves back to a previous step in the flow. + * + * @param string|null $step The step to move back to, or null to move back one step + * + * @throws RuntimeException If the previous step cannot be determined + */ + public function movePrevious(?string $step = null): void; + + /** + * Moves to the next step in the flow. + * + * @throws RuntimeException If the next step cannot be determined + */ + public function moveNext(): void; + + /** + * Creates a new form for the current step with initial options. + */ + public function newStepForm(): static; + + /** + * Gets the form for the current step, handling any action if needed. + * Returns a new step form if the current form is valid and submitted. + */ + public function getStepForm(): static; + + /** + * Returns the cursor that tracks the current position in the flow. + */ + public function getCursor(): FlowCursor; + + /** + * Returns the configuration for this flow. + */ + public function getConfig(): FormFlowConfigInterface; + + /** + * Checks if the flow has been completed. + */ + public function isFinished(): bool; +} diff --git a/src/Symfony/Component/Form/Flow/FormFlowTypeInterface.php b/src/Symfony/Component/Form/Flow/FormFlowTypeInterface.php new file mode 100644 index 000000000000..04b4af8a0384 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/FormFlowTypeInterface.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; + +use Symfony\Component\Form\FormTypeInterface; + +/** + * A type that should be converted into a {@link FormFlow} instance. + * + * @author Yonel Ceruto + */ +interface FormFlowTypeInterface extends FormTypeInterface +{ + public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void; +} diff --git a/src/Symfony/Component/Form/Flow/StepAccessor/PropertyPathStepAccessor.php b/src/Symfony/Component/Form/Flow/StepAccessor/PropertyPathStepAccessor.php new file mode 100644 index 000000000000..e5e4151cebf2 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/StepAccessor/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\StepAccessor; + +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/StepAccessor/StepAccessorInterface.php b/src/Symfony/Component/Form/Flow/StepAccessor/StepAccessorInterface.php new file mode 100644 index 000000000000..040824c0d88e --- /dev/null +++ b/src/Symfony/Component/Form/Flow/StepAccessor/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\StepAccessor; + +/** + * 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/Type/FlowButtonType.php b/src/Symfony/Component/Form/Flow/Type/FlowButtonType.php new file mode 100644 index 000000000000..f77ab7ccd760 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/Type/FlowButtonType.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Flow\FlowButtonTypeInterface; +use Symfony\Component\Form\Flow\FlowCursor; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * A submit button with a callable handler for a form flow. + * + * @author Yonel Ceruto + */ +class FlowButtonType extends AbstractType implements FlowButtonTypeInterface +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->define('handler') + ->info('The callable that will be called when this button is clicked') + ->required() + ->allowedTypes('callable'); + + $resolver->define('include_if') + ->info('Decide whether to include this button in the current form') + ->default(null) + ->allowedTypes('null', 'array', 'callable') + ->normalize(function (Options $options, mixed $value) { + if (\is_array($value)) { + return fn (FlowCursor $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(false) + ->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/Flow/Type/FlowFinishType.php b/src/Symfony/Component/Form/Flow/Type/FlowFinishType.php new file mode 100644 index 000000000000..ad1d73813e75 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/Type/FlowFinishType.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\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Flow\FlowButtonInterface; +use Symfony\Component\Form\Flow\FlowButtonTypeInterface; +use Symfony\Component\Form\Flow\FlowCursor; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class FlowFinishType extends AbstractType implements FlowButtonTypeInterface +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->setAttribute('action', 'finish'); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'handler' => fn (mixed $data, FlowButtonInterface $button, FormFlowInterface $flow) => $flow->reset(), + 'include_if' => fn (FlowCursor $cursor): bool => $cursor->isLastStep(), + ]); + } + + public function getParent(): string + { + return FlowButtonType::class; + } +} diff --git a/src/Symfony/Component/Form/Flow/Type/FlowNavigatorType.php b/src/Symfony/Component/Form/Flow/Type/FlowNavigatorType.php new file mode 100644 index 000000000000..591c3cdb9b3e --- /dev/null +++ b/src/Symfony/Component/Form/Flow/Type/FlowNavigatorType.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\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * A navigator type that defines default buttons to interact with a form flow. + * + * @author Yonel Ceruto + */ +class FlowNavigatorType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('previous', FlowPreviousType::class); + $builder->add('next', FlowNextType::class); + $builder->add('finish', FlowFinishType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'mapped' => false, + 'priority' => -100, + ]); + } +} diff --git a/src/Symfony/Component/Form/Flow/Type/FlowNextType.php b/src/Symfony/Component/Form/Flow/Type/FlowNextType.php new file mode 100644 index 000000000000..51f4b75f9b90 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/Type/FlowNextType.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\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Flow\FlowButtonInterface; +use Symfony\Component\Form\Flow\FlowButtonTypeInterface; +use Symfony\Component\Form\Flow\FlowCursor; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class FlowNextType extends AbstractType implements FlowButtonTypeInterface +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->setAttribute('action', 'next'); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'handler' => fn (mixed $data, FlowButtonInterface $button, FormFlowInterface $flow) => $flow->moveNext(), + 'include_if' => fn (FlowCursor $cursor): bool => $cursor->canMoveNext(), + ]); + } + + public function getParent(): string + { + return FlowButtonType::class; + } +} diff --git a/src/Symfony/Component/Form/Flow/Type/FlowPreviousType.php b/src/Symfony/Component/Form/Flow/Type/FlowPreviousType.php new file mode 100644 index 000000000000..bace5c24bbf8 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/Type/FlowPreviousType.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Flow\FlowButtonInterface; +use Symfony\Component\Form\Flow\FlowButtonTypeInterface; +use Symfony\Component\Form\Flow\FlowCursor; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class FlowPreviousType extends AbstractType implements FlowButtonTypeInterface +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->setAttribute('action', 'previous'); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'handler' => fn (mixed $data, FlowButtonInterface $button, FormFlowInterface $flow) => $flow->movePrevious($button->getViewData()), + 'include_if' => fn (FlowCursor $cursor): bool => $cursor->canMoveBack(), + 'clear_submission' => true, + ]); + } + + public function getParent(): string + { + return FlowButtonType::class; + } +} diff --git a/src/Symfony/Component/Form/Flow/Type/FlowResetType.php b/src/Symfony/Component/Form/Flow/Type/FlowResetType.php new file mode 100644 index 000000000000..ca1152a2c4c5 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/Type/FlowResetType.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\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Flow\FlowButtonInterface; +use Symfony\Component\Form\Flow\FlowButtonTypeInterface; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class FlowResetType extends AbstractType implements FlowButtonTypeInterface +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->setAttribute('action', 'reset'); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'handler' => fn (mixed $data, FlowButtonInterface $button, FormFlowInterface $flow) => $flow->reset(), + 'clear_submission' => true, + ]); + } + + public function getParent(): string + { + return FlowButtonType::class; + } +} diff --git a/src/Symfony/Component/Form/Flow/Type/FormFlowType.php b/src/Symfony/Component/Form/Flow/Type/FormFlowType.php new file mode 100644 index 000000000000..832ef28109d3 --- /dev/null +++ b/src/Symfony/Component/Form/Flow/Type/FormFlowType.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow\Type; + +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Flow\AbstractFlowType; +use Symfony\Component\Form\Flow\DataStorage\DataStorageInterface; +use Symfony\Component\Form\Flow\DataStorage\NullDataStorage; +use Symfony\Component\Form\Flow\FlowButtonInterface; +use Symfony\Component\Form\Flow\FormFlowBuilderInterface; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\Flow\StepAccessor\PropertyPathStepAccessor; +use Symfony\Component\Form\Flow\StepAccessor\StepAccessorInterface; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +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(); + } + + public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void + { + $builder->setDataStorage($options['data_storage'] ?? new NullDataStorage()); + $builder->setStepAccessor($options['step_accessor']); + + $builder->addEventListener(FormEvents::PRE_SUBMIT, $this->onPreSubmit(...), -100); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + \assert($form instanceof FormFlowInterface); + + $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; + } + + public function onPreSubmit(FormEvent $event): void + { + /** @var FormFlowInterface $flow */ + $flow = $event->getForm(); + $button = $flow->getClickedButton(); + + if ($button instanceof FlowButtonInterface && $button->isClearSubmission()) { + $event->setData([]); + } + } +} diff --git a/src/Symfony/Component/Form/FormFactory.php b/src/Symfony/Component/Form/FormFactory.php index dcf7b36f28d0..488440356be2 100644 --- a/src/Symfony/Component/Form/FormFactory.php +++ b/src/Symfony/Component/Form/FormFactory.php @@ -13,6 +13,9 @@ use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\Flow\FormFlowBuilderInterface; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\Flow\FormFlowTypeInterface; class FormFactory implements FormFactoryInterface { @@ -21,11 +24,17 @@ public function __construct( ) { } + /** + * @return ($type is class-string ? FormFlowInterface : FormInterface) + */ public function create(string $type = FormType::class, mixed $data = null, array $options = []): FormInterface { return $this->createBuilder($type, $data, $options)->getForm(); } + /** + * @return ($type is class-string ? FormFlowInterface : FormInterface) + */ public function createNamed(string $name, string $type = FormType::class, mixed $data = null, array $options = []): FormInterface { return $this->createNamedBuilder($name, $type, $data, $options)->getForm(); @@ -36,11 +45,17 @@ public function createForProperty(string $class, string $property, mixed $data = return $this->createBuilderForProperty($class, $property, $data, $options)->getForm(); } + /** + * @return ($type is class-string ? FormFlowBuilderInterface : FormBuilderInterface) + */ public function createBuilder(string $type = FormType::class, mixed $data = null, array $options = []): FormBuilderInterface { return $this->createNamedBuilder($this->registry->getType($type)->getBlockPrefix(), $type, $data, $options); } + /** + * @return ($type is class-string ? FormFlowBuilderInterface : FormBuilderInterface) + */ public function createNamedBuilder(string $name, string $type = FormType::class, mixed $data = null, array $options = []): FormBuilderInterface { if (null !== $data && !\array_key_exists('data', $options)) { @@ -51,6 +66,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 0f311c0e57cb..e8c72c07c01a 100644 --- a/src/Symfony/Component/Form/FormFactoryInterface.php +++ b/src/Symfony/Component/Form/FormFactoryInterface.php @@ -12,6 +12,9 @@ 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; /** @@ -28,6 +31,8 @@ interface FormFactoryInterface * * @param mixed $data The initial data * + * @return ($type is class-string ? FormFlowInterface : FormInterface) + * * @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 +44,8 @@ public function create(string $type = FormType::class, mixed $data = null, array * * @param mixed $data The initial data * + * @return ($type is class-string ? FormFlowInterface : FormInterface) + * * @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; @@ -61,6 +68,8 @@ public function createForProperty(string $class, string $property, mixed $data = * * @param mixed $data The initial data * + * @return ($type is class-string ? FormFlowBuilderInterface : FormBuilderInterface) + * * @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 +79,8 @@ public function createBuilder(string $type = FormType::class, mixed $data = null * * @param mixed $data The initial data * + * @return ($type is class-string ? FormFlowBuilderInterface : FormBuilderInterface) + * * @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; diff --git a/src/Symfony/Component/Form/ResolvedFormType.php b/src/Symfony/Component/Form/ResolvedFormType.php index 81c9c396de44..671424afd4c3 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\FlowButtonBuilder; +use Symfony\Component\Form\Flow\FlowButtonTypeInterface; +use Symfony\Component\Form\Flow\FormFlowBuilder; +use Symfony\Component\Form\Flow\FormFlowTypeInterface; use Symfony\Component\OptionsResolver\Exception\ExceptionInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -118,7 +122,6 @@ public function finishView(FormView $view, FormInterface $form, array $options): $this->innerType->finishView($view, $form, $options); foreach ($this->typeExtensions as $extension) { - /** @var FormTypeExtensionInterface $extension */ $extension->finishView($view, $form, $options); } } @@ -157,6 +160,14 @@ protected function newBuilder(string $name, ?string $dataClass, FormFactoryInter return new SubmitButtonBuilder($name, $options); } + if ($this->innerType instanceof FlowButtonTypeInterface) { + return new FlowButtonBuilder($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/Test/FormInterface.php b/src/Symfony/Component/Form/Test/FormInterface.php index 4af46030870c..90248227ac30 100644 --- a/src/Symfony/Component/Form/Test/FormInterface.php +++ b/src/Symfony/Component/Form/Test/FormInterface.php @@ -13,6 +13,9 @@ use Symfony\Component\Form\FormInterface as BaseFormInterface; +/** + * @extends \Iterator + */ interface FormInterface extends \Iterator, BaseFormInterface { } 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 000000000000..9b14701b2055 --- /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 000000000000..b688314fd7a6 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Fixtures/Flow/Extension/UserSignUpTypeExtension.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\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 +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + \assert($builder instanceof FormFlowBuilderInterface); + + $builder->addStep('first', FormType::class, ['mapped' => false], priority: 1); + $builder->addStep('last', FormType::class, ['mapped' => false]); + } + + public static function getExtendedTypes(): iterable + { + return [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 000000000000..cd6f9c5a3c63 --- /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 000000000000..3132eb6ccaf8 --- /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 000000000000..50f9b48b1579 --- /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 000000000000..58dba34fe141 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Fixtures/Flow/UserSignUpNavigatorType.php @@ -0,0 +1,36 @@ + + * + * 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\Flow\Type\FlowNavigatorType; +use Symfony\Component\Form\Flow\Type\FlowNextType; +use Symfony\Component\Form\Flow\Type\FlowResetType; +use Symfony\Component\Form\FormBuilderInterface; + +class UserSignUpNavigatorType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('skip', FlowNextType::class, [ + 'clear_submission' => true, + 'include_if' => ['professional'], + ]); + + $builder->add('reset', FlowResetType::class); + } + + public function getParent(): string + { + return FlowNavigatorType::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 000000000000..119d163df080 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Fixtures/Flow/UserSignUpType.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\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\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 +{ + public function buildFormFlow(FormFlowBuilderInterface $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/FormFlowBuilderTest.php b/src/Symfony/Component/Form/Tests/Flow/FormFlowBuilderTest.php new file mode 100644 index 000000000000..7b5ee109f66b --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Flow/FormFlowBuilderTest.php @@ -0,0 +1,92 @@ + + * + * 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\EventDispatcher\EventDispatcher; +use Symfony\Component\Form\Exception\InvalidArgumentException; +use Symfony\Component\Form\Exception\LogicException; +use Symfony\Component\Form\Flow\DataStorage\InMemoryDataStorage; +use Symfony\Component\Form\Flow\FormFlowBuilder; +use Symfony\Component\Form\Flow\StepAccessor\PropertyPathStepAccessor; +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\Form\Forms; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyPath; + +class FormFlowBuilderTest extends TestCase +{ + private FormFactoryInterface $factory; + private InMemoryDataStorage $dataStorage; + private PropertyPathStepAccessor $stepAccessor; + + protected function setUp(): void + { + $this->factory = Forms::createFormFactoryBuilder()->getFormFactory(); + $this->dataStorage = new InMemoryDataStorage('key'); + $this->stepAccessor = new PropertyPathStepAccessor(PropertyAccess::createPropertyAccessor(), new PropertyPath('[currentStep]')); + } + + public function testNoStepsConfigured() + { + $builder = new FormFlowBuilder('test', null, new EventDispatcher(), $this->factory); + $builder->setData([]); + $builder->setDataStorage($this->dataStorage); + $builder->setStepAccessor($this->stepAccessor); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Steps not configured.'); + + $builder->getForm(); + } + + public function testRemoveAllStepsDynamically() + { + $builder = new FormFlowBuilder('test', null, new EventDispatcher(), $this->factory); + $builder->setData([]); + $builder->setDataStorage($this->dataStorage); + $builder->setStepAccessor($this->stepAccessor); + $builder->addStep('step1'); + + // In a type extension context + $builder->removeStep('step1'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Steps not configured.'); + + $builder->getForm(); + } + + public function testNestedFormFlowException() + { + // Create parent form flow builder + $builder = new FormFlowBuilder('parent', null, new EventDispatcher(), $this->factory); + $builder->setData([]); + $builder->setDataStorage($this->dataStorage); + $builder->setStepAccessor($this->stepAccessor); + $builder->addStep('step1'); + + // Create child form flow builder + $childBuilder = new FormFlowBuilder('child', null, new EventDispatcher(), $this->factory); + $childBuilder->setDataStorage(new InMemoryDataStorage('child_key')); + $childBuilder->setStepAccessor($this->stepAccessor); + $childBuilder->addStep('child_step1'); + + // Add child form flow to parent + $builder->add($childBuilder); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Nested form flows is not currently supported.'); + + $builder->getForm(); + } +} diff --git a/src/Symfony/Component/Form/Tests/Flow/FormFlowCursorTest.php b/src/Symfony/Component/Form/Tests/Flow/FormFlowCursorTest.php new file mode 100644 index 000000000000..68ebe8c6cde8 --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Flow/FormFlowCursorTest.php @@ -0,0 +1,202 @@ + + * + * 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\InvalidArgumentException; +use Symfony\Component\Form\Flow\FlowCursor; + +class FormFlowCursorTest extends TestCase +{ + private static array $steps = ['personal', 'professional', 'account']; + + public function testConstructorWithValidStep() + { + $cursor = new FlowCursor(self::$steps, 'personal'); + + $this->assertSame(self::$steps, $cursor->getSteps()); + $this->assertSame('personal', $cursor->getCurrentStep()); + } + + public function testConstructorWithInvalidStep() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Step "invalid" does not exist. Available steps are: "personal", "professional", "account".'); + + new FlowCursor(self::$steps, 'invalid'); + } + + public function testGetSteps() + { + $cursor = new FlowCursor(self::$steps, 'personal'); + + $this->assertSame(self::$steps, $cursor->getSteps()); + } + + public function testGetTotalSteps() + { + $cursor = new FlowCursor(self::$steps, 'personal'); + + $this->assertSame(3, $cursor->getTotalSteps()); + } + + public function testGetStepIndex() + { + $cursor = new FlowCursor(self::$steps, 'personal'); + $this->assertSame(0, $cursor->getStepIndex()); + + $cursor = new FlowCursor(self::$steps, 'professional'); + $this->assertSame(1, $cursor->getStepIndex()); + + $cursor = new FlowCursor(self::$steps, 'account'); + $this->assertSame(2, $cursor->getStepIndex()); + } + + public function testGetFirstStep() + { + $cursor = new FlowCursor(self::$steps, 'professional'); + + $this->assertSame('personal', $cursor->getFirstStep()); + } + + public function testGetPrevStep() + { + // First step has no previous step + $cursor = new FlowCursor(self::$steps, 'personal'); + $this->assertNull($cursor->getPreviousStep()); + + // Middle step has previous step + $cursor = new FlowCursor(self::$steps, 'professional'); + $this->assertSame('personal', $cursor->getPreviousStep()); + + // Last step has previous step + $cursor = new FlowCursor(self::$steps, 'account'); + $this->assertSame('professional', $cursor->getPreviousStep()); + } + + public function testGetCurrentStep() + { + $cursor = new FlowCursor(self::$steps, 'professional'); + + $this->assertSame('professional', $cursor->getCurrentStep()); + } + + public function testWithCurrentStep() + { + $cursor = new FlowCursor(self::$steps, 'personal'); + + $newCursor = $cursor->withCurrentStep('professional'); + + // Original cursor should remain unchanged + $this->assertSame('personal', $cursor->getCurrentStep()); + + // New cursor should have the new current step + $this->assertSame('professional', $newCursor->getCurrentStep()); + + // Both cursors should have the same steps + $this->assertSame(self::$steps, $cursor->getSteps()); + $this->assertSame(self::$steps, $newCursor->getSteps()); + } + + public function testGetNextStep() + { + // First step has next step + $cursor = new FlowCursor(self::$steps, 'personal'); + $this->assertSame('professional', $cursor->getNextStep()); + + // Middle step has next step + $cursor = new FlowCursor(self::$steps, 'professional'); + $this->assertSame('account', $cursor->getNextStep()); + + // Last step has no next step + $cursor = new FlowCursor(self::$steps, 'account'); + $this->assertNull($cursor->getNextStep()); + } + + public function testGetLastStep() + { + $cursor = new FlowCursor(self::$steps, 'personal'); + + $this->assertSame('account', $cursor->getLastStep()); + } + + public function testIsFirstStep() + { + // First step + $cursor = new FlowCursor(self::$steps, 'personal'); + $this->assertTrue($cursor->isFirstStep()); + + // Not first step + $cursor = new FlowCursor(self::$steps, 'professional'); + $this->assertFalse($cursor->isFirstStep()); + } + + public function testIsLastStep() + { + // Not last step + $cursor = new FlowCursor(self::$steps, 'personal'); + $this->assertFalse($cursor->isLastStep()); + + // Last step + $cursor = new FlowCursor(self::$steps, 'account'); + $this->assertTrue($cursor->isLastStep()); + } + + public function testCanMovePreviousStep() + { + // First position cannot move a previous step + $cursor = new FlowCursor(self::$steps, 'personal'); + $this->assertFalse($cursor->canMoveBack()); + + // Middle position can move a previous step + $cursor = new FlowCursor(self::$steps, 'professional'); + $this->assertTrue($cursor->canMoveBack()); + + // Last step can move a previous step + $cursor = new FlowCursor(self::$steps, 'account'); + $this->assertTrue($cursor->canMoveBack()); + } + + public function testCanMoveNext() + { + // First position can move next step + $cursor = new FlowCursor(self::$steps, 'personal'); + $this->assertTrue($cursor->canMoveNext()); + + // Middle position can move next step + $cursor = new FlowCursor(self::$steps, 'professional'); + $this->assertTrue($cursor->canMoveNext()); + + // Last position cannot move the next step + $cursor = new FlowCursor(self::$steps, 'account'); + $this->assertFalse($cursor->canMoveNext()); + } + + public function testCursorWithSingleStep() + { + $steps = ['single']; + $cursor = new FlowCursor($steps, 'single'); + + $this->assertSame('single', $cursor->getCurrentStep()); + $this->assertTrue($cursor->isFirstStep()); + $this->assertTrue($cursor->isLastStep()); + $this->assertSame('single', $cursor->getFirstStep()); + $this->assertNull($cursor->getPreviousStep()); + $this->assertNull($cursor->getNextStep()); + $this->assertSame('single', $cursor->getLastStep()); + $this->assertSame(['single'], $cursor->getSteps()); + $this->assertSame(0, $cursor->getStepIndex()); + $this->assertSame(1, $cursor->getTotalSteps()); + $this->assertFalse($cursor->canMoveBack()); + $this->assertFalse($cursor->canMoveNext()); + } +} 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 000000000000..1192ed186f2c --- /dev/null +++ b/src/Symfony/Component/Form/Tests/Flow/FormFlowTest.php @@ -0,0 +1,935 @@ + + * + * 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\Validator\ValidatorExtension; +use Symfony\Component\Form\Flow\DataStorage\InMemoryDataStorage; +use Symfony\Component\Form\Flow\FlowButtonInterface; +use Symfony\Component\Form\Flow\FlowCursor; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\Flow\Type\FlowNextType; +use Symfony\Component\Form\Flow\Type\FlowPreviousType; +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->getPreviousStep()); + 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->getPreviousStep()); + 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->getPreviousStep()); + 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->getClickedButton()); + 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->getClickedButton()); + 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->getClickedButton()); + 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('previous')); + 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->getClickedButton()); + 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->getClickedButton()); + 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('previous')); + 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->getClickedButton()); + 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 testPreviousActionWithPurgeSubmission() + { + $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' => [ + 'previous' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedButton()); + self::assertTrue($button->isPreviousAction()); + + $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 testPreviousActionWithoutPurgeSubmission() + { + $data = new UserSignUp(); + $data->firstName = 'John'; + $data->lastName = 'Doe'; + $data->worker = true; + $data->currentStep = 'professional'; + + $flow = $this->factory->create(UserSignUpType::class, $data); + // previous action without purge submission + $flow->get('navigator')->add('previous', FlowPreviousType::class, [ + 'validate' => false, + 'validation_groups' => false, + 'clear_submission' => false, + 'include_if' => fn (FlowCursor $cursor) => $cursor->canMoveBack(), + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('professional')); + + $flow->submit([ + 'professional' => [ + 'company' => 'Acme', + 'role' => 'ROLE_DEVELOPER', + ], + 'navigator' => [ + 'previous' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedButton()); + self::assertTrue($button->isPreviousAction()); + + $flow = $flow->getStepForm(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal'), 'previous 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->getClickedButton()); + 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->getClickedButton()); + 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->getClickedButton()); + 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', FlowPreviousType::class, [ + '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->getClickedButton()); + self::assertTrue($button->isPreviousAction()); + 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->movePrevious(); + $flow = $flow->newStepForm(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal')); + + $flow->moveNext(); + $flow = $flow->newStepForm(); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('professional')); + } + + public function testInvalidMovePreviousUntilAheadStep() + { + $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->movePrevious('account'); + } + + public function testInvalidMovePreviousUntilSkippedStep() + { + $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->movePrevious('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->getClickedButton()); + 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('FlowStepBuilder methods cannot be accessed anymore once the builder is turned into a FlowStepConfigInterface 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' => [ + 'previous' => '', + ], + ]); + + self::assertFalse($flow->isSubmitted()); + } + + public function testViewVars() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + $view = $flow->createView(); + + self::assertInstanceOf(FlowCursor::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->getClickedButton()); + 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->getClickedButton()); + 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->getClickedButton()); + 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('previous')); + 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->getClickedButton()); + 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->getClickedButton()); + 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('previous')); + 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->getClickedButton()); + 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::assertNotNull($actionButton = $flow->getClickedButton()); + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + + $actionButton->handle(); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + } + + public function testAddFormErrorOnActionHandling() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + $flow->get('navigator')->add('next', FlowNextType::class, [ + 'handler' => function (mixed $data, FlowButtonInterface $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::assertNotNull($actionButton = $flow->getClickedButton()); + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + + $actionButton->handle(); + $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()); + } + + public function testStepValidationGroups() + { + $data = new UserSignUp(); + $data->worker = true; + $flow = $this->factory->create(UserSignUpType::class, $data); + + // Check that validation groups include the current step name + self::assertSame(['Default', 'personal'], $flow->getConfig()->getOption('validation_groups')($flow)); + + // Move to next step + $flow->moveNext(); + $flow = $flow->newStepForm(); + + // Check that validation groups are updated + self::assertEquals(['Default', 'professional'], $flow->getConfig()->getOption('validation_groups')($flow)); + } +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy