diff --git a/CHANGELOG.md b/CHANGELOG.md index 75a83e96..cf7daff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * Add `input=date_point` to `DateTimeType`, `DateType` and `TimeType` * Add support for guessing form type of enum properties * Add `active_at`, `not_active_at` and `legal_tender` options to `CurrencyType` + * Add `FormFlow` for multistep forms management 7.3 --- diff --git a/Extension/Core/CoreExtension.php b/Extension/Core/CoreExtension.php index 1640ed05..9555c09d 100644 --- a/Extension/Core/CoreExtension.php +++ b/Extension/Core/CoreExtension.php @@ -17,6 +17,7 @@ use Symfony\Component\Form\ChoiceList\Factory\ChoiceListFactoryInterface; 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 @@ class CoreExtension extends AbstractExtension new Type\TelType(), new Type\ColorType($this->translator), new Type\WeekType(), + new Flow\Type\ButtonFlowType(), + new Flow\Type\FinishFlowType(), + new Flow\Type\NavigatorFlowType(), + new Flow\Type\NextFlowType(), + new Flow\Type\PreviousFlowType(), + new Flow\Type\FormFlowType($this->propertyAccessor), ]; } diff --git a/Extension/HttpFoundation/HttpFoundationExtension.php b/Extension/HttpFoundation/HttpFoundationExtension.php index 85bc4f47..0a17bf21 100644 --- a/Extension/HttpFoundation/HttpFoundationExtension.php +++ b/Extension/HttpFoundation/HttpFoundationExtension.php @@ -24,6 +24,7 @@ class HttpFoundationExtension extends AbstractExtension { return [ new Type\FormTypeHttpFoundationExtension(), + new Type\FormFlowTypeSessionDataStorageExtension(), ]; } } diff --git a/Extension/HttpFoundation/Type/FormFlowTypeSessionDataStorageExtension.php b/Extension/HttpFoundation/Type/FormFlowTypeSessionDataStorageExtension.php new file mode 100644 index 00000000..cfa18a8a --- /dev/null +++ b/Extension/HttpFoundation/Type/FormFlowTypeSessionDataStorageExtension.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\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 + { + if (!$builder instanceof FormFlowBuilderInterface) { + throw new \InvalidArgumentException(\sprintf('The "%s" can only be used with FormFlowType.', self::class)); + } + + 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/Flow/AbstractButtonFlowType.php b/Flow/AbstractButtonFlowType.php new file mode 100644 index 00000000..5dcbed57 --- /dev/null +++ b/Flow/AbstractButtonFlowType.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; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Flow\Type\ButtonFlowType; + +/** + * @author Yonel Ceruto + */ +abstract class AbstractButtonFlowType extends AbstractType implements ButtonFlowTypeInterface +{ + public function getParent(): string + { + return ButtonFlowType::class; + } +} diff --git a/Flow/AbstractFlowType.php b/Flow/AbstractFlowType.php new file mode 100644 index 00000000..be5e2718 --- /dev/null +++ b/Flow/AbstractFlowType.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\AbstractType; +use Symfony\Component\Form\Flow\Type\FormFlowType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; + +/** + * @author Yonel Ceruto + */ +abstract class AbstractFlowType extends AbstractType implements FormFlowTypeInterface +{ + final public function buildForm(FormBuilderInterface $builder, array $options): void + { + if (!$builder instanceof FormFlowBuilderInterface) { + throw new \InvalidArgumentException(\sprintf('The "%s" can only be used with FormFlowType.', self::class)); + } + + $this->buildFormFlow($builder, $options); + } + + final public function buildView(FormView $view, FormInterface $form, array $options): void + { + if (!$form instanceof FormFlowInterface) { + throw new \InvalidArgumentException(\sprintf('The "%s" can only be used with FormFlowType.', self::class)); + } + + $this->buildViewFlow($view, $form, $options); + } + + final public function finishView(FormView $view, FormInterface $form, array $options): void + { + if (!$form instanceof FormFlowInterface) { + throw new \InvalidArgumentException(\sprintf('The "%s" can only be used with FormFlowType.', self::class)); + } + + $this->finishViewFlow($view, $form, $options); + } + + public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void + { + } + + public function buildViewFlow(FormView $view, FormFlowInterface $form, array $options): void + { + } + + public function finishViewFlow(FormView $view, FormFlowInterface $form, array $options): void + { + } + + public function getParent(): string + { + return FormFlowType::class; + } +} diff --git a/Flow/ButtonFlow.php b/Flow/ButtonFlow.php new file mode 100644 index 00000000..9e4c6bd0 --- /dev/null +++ b/Flow/ButtonFlow.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 ButtonFlow extends SubmitButton implements ButtonFlowInterface +{ + 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/Flow/ButtonFlowBuilder.php b/Flow/ButtonFlowBuilder.php new file mode 100644 index 00000000..678142a9 --- /dev/null +++ b/Flow/ButtonFlowBuilder.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 ButtonFlow} instances. + * + * @author Yonel Ceruto + */ +class ButtonFlowBuilder extends ButtonBuilder +{ + public function getForm(): ButtonFlow + { + return new ButtonFlow($this->getFormConfig()); + } +} diff --git a/Flow/ButtonFlowInterface.php b/Flow/ButtonFlowInterface.php new file mode 100644 index 00000000..fcc5f8af --- /dev/null +++ b/Flow/ButtonFlowInterface.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 ButtonFlowInterface 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/Flow/ButtonFlowTypeInterface.php b/Flow/ButtonFlowTypeInterface.php new file mode 100644 index 00000000..4dcabdc7 --- /dev/null +++ b/Flow/ButtonFlowTypeInterface.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 ButtonFlow} instance. + * + * @author Yonel Ceruto + */ +interface ButtonFlowTypeInterface extends FormTypeInterface +{ +} diff --git a/Flow/DataStorage/DataStorageInterface.php b/Flow/DataStorage/DataStorageInterface.php new file mode 100644 index 00000000..5b9f188a --- /dev/null +++ b/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/Flow/DataStorage/InMemoryDataStorage.php b/Flow/DataStorage/InMemoryDataStorage.php new file mode 100644 index 00000000..b1eb12f5 --- /dev/null +++ b/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/Flow/DataStorage/NullDataStorage.php b/Flow/DataStorage/NullDataStorage.php new file mode 100644 index 00000000..09ad2f78 --- /dev/null +++ b/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/Flow/DataStorage/SessionDataStorage.php b/Flow/DataStorage/SessionDataStorage.php new file mode 100644 index 00000000..44f4445b --- /dev/null +++ b/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/Flow/FormFlow.php b/Flow/FormFlow.php new file mode 100644 index 00000000..b1bdd7b0 --- /dev/null +++ b/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 ?ButtonFlowInterface $clickedFlowButton = null; + private bool $finished = false; + + public function __construct( + private readonly FormFlowConfigInterface $config, + private FormFlowCursor $cursor, + ) { + parent::__construct($config); + } + + public function submit(mixed $submittedData, bool $clearMissing = true): static + { + if ($this->isSubmitted()) { + throw new AlreadySubmittedException('A form can only be submitted once.'); + } + + if (!\is_array($submittedData)) { + throw new TransformationFailedException('The submitted data must be an array.'); + } + + if (!$this->isCurrentStepSubmitted($submittedData)) { + // the submitted data doesn't match the current step, + // it's probably a reload of a POST visit from a different step + return $this; + } + + $this->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 (FormFlowCursor $cursor) => $cursor->getPreviousStep())) { + throw new RuntimeException('Cannot determine previous step.'); + } + } + + public function moveNext(): void + { + if (!$this->move(fn (FormFlowCursor $cursor) => $cursor->getNextStep())) { + throw new RuntimeException('Cannot determine next step.'); + } + } + + public function 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(): FormFlowCursor + { + return $this->cursor; + } + + public function getConfig(): FormFlowConfigInterface + { + return $this->config; + } + + public function isFinished(): bool + { + return $this->finished; + } + + public function getClickedButton(): ButtonFlowInterface|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 ButtonFlowInterface) { + 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/Flow/FormFlowBuilder.php b/Flow/FormFlowBuilder.php new file mode 100644 index 00000000..43f9cd4e --- /dev/null +++ b/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 = []): StepFlowBuilderConfigInterface + { + if ($this->locked) { + throw new BadMethodCallException('FormFlowBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowConfigInterface instance.'); + } + + return new StepFlowBuilder($name, $type, $options); + } + + public function addStep(StepFlowBuilderConfigInterface|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 StepFlowBuilderConfigInterface) { + $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): StepFlowBuilderConfigInterface + { + 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 (StepFlowBuilderConfigInterface $a, StepFlowBuilderConfigInterface $b) { + return $b->getPriority() <=> $a->getPriority(); + }); + + $currentStep = $this->resolveCurrentStep(); + + if (!isset($this->steps[$currentStep])) { + throw new InvalidArgumentException(\sprintf('Step form "%s" is not defined.', $currentStep)); + } + + $step = $this->steps[$currentStep]; + $this->add($step->getName(), $step->getType(), $step->getOptions()); + + $cursor = new FormFlowCursor(array_keys($this->steps), $currentStep); + $this->pruneActionButtons($this, $cursor); + + return new FormFlow($this->getFormConfig(), $cursor); + } + + private function resolveCurrentStep(): string + { + $data = $this->getData(); + + if (!$currentStep = $this->getStepAccessor()->getStep($data)) { + $currentStep = key($this->steps); + $this->getStepAccessor()->setStep($data, $currentStep); + $this->setData($data); + } + + return $currentStep; + } + + private function pruneActionButtons(FormBuilderInterface $builder, FormFlowCursor $cursor): void + { + foreach ($builder->all() as $child) { + if ($child->count() > 0) { + $this->pruneActionButtons($child, $cursor); + + continue; + } + + if (!$child instanceof ButtonFlowBuilder || !\is_callable($include = $child->getOption('include_if'))) { + continue; + } + + if (!$include($cursor)) { + $builder->remove($child->getName()); + } + } + } +} diff --git a/Flow/FormFlowBuilderInterface.php b/Flow/FormFlowBuilderInterface.php new file mode 100644 index 00000000..27d70636 --- /dev/null +++ b/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 = []): StepFlowBuilderConfigInterface; + + /** + * Adds a step to the form flow. + */ + public function addStep(StepFlowBuilderConfigInterface|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): StepFlowBuilderConfigInterface; + + /** + * 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/Flow/FormFlowConfigInterface.php b/Flow/FormFlowConfigInterface.php new file mode 100644 index 00000000..8ef2d556 --- /dev/null +++ b/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): StepFlowConfigInterface; + + /** + * 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/Flow/FormFlowCursor.php b/Flow/FormFlowCursor.php new file mode 100644 index 00000000..33138d9e --- /dev/null +++ b/Flow/FormFlowCursor.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow; + +use Symfony\Component\Form\Exception\InvalidArgumentException; + +/** + * @author Yonel Ceruto + */ +class FormFlowCursor +{ + /** + * @param array $steps + */ + public function __construct( + private readonly array $steps, + private readonly string $currentStep, + ) { + if (!\in_array($currentStep, $steps, true)) { + throw new InvalidArgumentException(\sprintf('Step "%s" does not exist. Available steps are: "%s".', $currentStep, implode('", "', $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/Flow/FormFlowInterface.php b/Flow/FormFlowInterface.php new file mode 100644 index 00000000..cdb38f05 --- /dev/null +++ b/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(): ButtonFlowInterface|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(): FormFlowCursor; + + /** + * Returns the configuration for this flow. + */ + public function getConfig(): FormFlowConfigInterface; + + /** + * Checks if the flow has been completed. + */ + public function isFinished(): bool; +} diff --git a/Flow/FormFlowTypeInterface.php b/Flow/FormFlowTypeInterface.php new file mode 100644 index 00000000..54f4d990 --- /dev/null +++ b/Flow/FormFlowTypeInterface.php @@ -0,0 +1,62 @@ + + * + * 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; +use Symfony\Component\Form\FormView; + +/** + * A type that should be converted into a {@link FormFlow} instance. + * + * @author Yonel Ceruto + */ +interface FormFlowTypeInterface extends FormTypeInterface +{ + /** + * Builds the multistep form. + * + * This method is called for each multistep type. Type extensions can further + * modify the multistep form. + * + * @param array $options + */ + public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void; + + /** + * Builds the multistep form view. + * + * This method is called for each multistep type. Type extensions can further + * modify the view. + * + * A view of a multistep form is built before the views of the child forms are built. + * This means that you cannot access child views in this method. If you need + * to do so, move your logic to {@link finishViewFlow()} instead. + * + * @param array $options + */ + public function buildViewFlow(FormView $view, FormFlowInterface $form, array $options): void; + + /** + * Finishes the multistep form view. + * + * This method gets called for each multistep type. Type extensions can further + * modify the view. + * + * When this method is called, views of the multistep form's children have already + * been built and finished and can be accessed. You should only implement + * such logic in this method that actually accesses child views. For everything + * else you are recommended to implement {@link buildViewFlow()} instead. + * + * @param array $options + */ + public function finishViewFlow(FormView $view, FormFlowInterface $form, array $options): void; +} diff --git a/Flow/StepAccessor/PropertyPathStepAccessor.php b/Flow/StepAccessor/PropertyPathStepAccessor.php new file mode 100644 index 00000000..e5e4151c --- /dev/null +++ b/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/Flow/StepAccessor/StepAccessorInterface.php b/Flow/StepAccessor/StepAccessorInterface.php new file mode 100644 index 00000000..040824c0 --- /dev/null +++ b/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/Flow/StepFlowBuilder.php b/Flow/StepFlowBuilder.php new file mode 100644 index 00000000..635b720a --- /dev/null +++ b/Flow/StepFlowBuilder.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 StepFlowBuilder implements StepFlowBuilderConfigInterface +{ + 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('StepFlowBuilder methods cannot be accessed anymore once the builder is turned into a StepFlowConfigInterface instance.'); + } + + return $this->type; + } + + public function getOptions(): array + { + if ($this->locked) { + throw new BadMethodCallException('StepFlowBuilder methods cannot be accessed anymore once the builder is turned into a StepFlowConfigInterface instance.'); + } + + return $this->options; + } + + public function getPriority(): int + { + return $this->priority; + } + + public function setPriority(int $priority): static + { + if ($this->locked) { + throw new BadMethodCallException('StepFlowBuilder methods cannot be accessed anymore once the builder is turned into a StepFlowConfigInterface 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('StepFlowBuilder methods cannot be accessed anymore once the builder is turned into a StepFlowConfigInterface instance.'); + } + + $this->skip = $skip; + + return $this; + } + + public function getStepConfig(): StepFlowConfigInterface + { + if ($this->locked) { + throw new BadMethodCallException('StepFlowBuilder methods cannot be accessed anymore once the builder is turned into a StepFlowConfigInterface instance.'); + } + + // This method should be idempotent, so clone the builder + $config = clone $this; + $config->locked = true; + + return $config; + } +} diff --git a/Flow/StepFlowBuilderConfigInterface.php b/Flow/StepFlowBuilderConfigInterface.php new file mode 100644 index 00000000..ee8d2a6d --- /dev/null +++ b/Flow/StepFlowBuilderConfigInterface.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 StepFlowBuilderConfigInterface extends StepFlowConfigInterface +{ + /** + * 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 StepFlowConfigInterface instance for the step. + */ + public function getStepConfig(): StepFlowConfigInterface; +} diff --git a/Flow/StepFlowConfigInterface.php b/Flow/StepFlowConfigInterface.php new file mode 100644 index 00000000..bb37984d --- /dev/null +++ b/Flow/StepFlowConfigInterface.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 StepFlowConfigInterface +{ + /** + * 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/Flow/Type/ButtonFlowType.php b/Flow/Type/ButtonFlowType.php new file mode 100644 index 00000000..63cffbb7 --- /dev/null +++ b/Flow/Type/ButtonFlowType.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\ButtonFlowTypeInterface; +use Symfony\Component\Form\Flow\FormFlowCursor; +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 ButtonFlowType extends AbstractType implements ButtonFlowTypeInterface +{ + 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 (FormFlowCursor $cursor): bool => \in_array($cursor->getCurrentStep(), $value, true); + } + + return $value; + }); + + $resolver->define('clear_submission') + ->info('Whether the submitted data will be cleared when this button is clicked') + ->default(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/Flow/Type/FinishFlowType.php b/Flow/Type/FinishFlowType.php new file mode 100644 index 00000000..cedd2dcd --- /dev/null +++ b/Flow/Type/FinishFlowType.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow\Type; + +use Symfony\Component\Form\Flow\AbstractButtonFlowType; +use Symfony\Component\Form\Flow\ButtonFlowInterface; +use Symfony\Component\Form\Flow\FormFlowCursor; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class FinishFlowType extends AbstractButtonFlowType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->setAttribute('action', 'finish'); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'handler' => fn (mixed $data, ButtonFlowInterface $button, FormFlowInterface $flow) => $flow->reset(), + 'include_if' => fn (FormFlowCursor $cursor): bool => $cursor->isLastStep(), + ]); + } +} diff --git a/Flow/Type/FormFlowType.php b/Flow/Type/FormFlowType.php new file mode 100644 index 00000000..becb763b --- /dev/null +++ b/Flow/Type/FormFlowType.php @@ -0,0 +1,129 @@ + + * + * 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\ButtonFlowInterface; +use Symfony\Component\Form\Flow\DataStorage\DataStorageInterface; +use Symfony\Component\Form\Flow\DataStorage\NullDataStorage; +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\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 buildViewFlow(FormView $view, FormFlowInterface $form, array $options): void + { + $view->vars['cursor'] = $cursor = $form->getCursor(); + + $index = 0; + $position = 1; + foreach ($form->getConfig()->getSteps() as $name => $step) { + $isSkipped = $step->isSkipped($form->getViewData()); + + $stepVars = [ + 'name' => $name, + 'index' => $index++, + 'position' => $isSkipped ? -1 : $position++, + 'is_current_step' => $name === $cursor->getCurrentStep(), + 'can_be_skipped' => null !== $step->getSkip(), + 'is_skipped' => $isSkipped, + ]; + + $view->vars['steps'][$name] = $stepVars; + + if (!$isSkipped) { + $view->vars['visible_steps'][$name] = $stepVars; + } + } + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->define('data_storage') + ->default(null) + ->allowedTypes('null', DataStorageInterface::class); + + $resolver->define('step_accessor') + ->default(function (Options $options) { + if (!isset($options['step_property_path'])) { + throw new MissingOptionsException('Option "step_property_path" is required.'); + } + + return new PropertyPathStepAccessor($this->propertyAccessor, $options['step_property_path']); + }) + ->allowedTypes(StepAccessorInterface::class); + + $resolver->define('step_property_path') + ->info('Required if the default step_accessor is being used') + ->allowedTypes('string', PropertyPathInterface::class) + ->normalize(function (Options $options, string|PropertyPathInterface $value): PropertyPathInterface { + return \is_string($value) ? new PropertyPath($value) : $value; + }); + + $resolver->define('auto_reset') + ->info('Whether the FormFlow will be reset automatically when it is finished') + ->default(true) + ->allowedTypes('bool'); + + $resolver->setDefault('validation_groups', function (FormFlowInterface $flow) { + return ['Default', $flow->getCursor()->getCurrentStep()]; + }); + } + + public function getParent(): string + { + return FormType::class; + } + + public function onPreSubmit(FormEvent $event): void + { + /** @var FormFlowInterface $flow */ + $flow = $event->getForm(); + $button = $flow->getClickedButton(); + + if ($button instanceof ButtonFlowInterface && $button->isClearSubmission()) { + $event->setData([]); + } + } +} diff --git a/Flow/Type/NavigatorFlowType.php b/Flow/Type/NavigatorFlowType.php new file mode 100644 index 00000000..7dab23c3 --- /dev/null +++ b/Flow/Type/NavigatorFlowType.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 NavigatorFlowType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('previous', PreviousFlowType::class); + $builder->add('next', NextFlowType::class); + $builder->add('finish', FinishFlowType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'label' => false, + 'mapped' => false, + 'priority' => -100, + ]); + } +} diff --git a/Flow/Type/NextFlowType.php b/Flow/Type/NextFlowType.php new file mode 100644 index 00000000..af1eaaca --- /dev/null +++ b/Flow/Type/NextFlowType.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form\Flow\Type; + +use Symfony\Component\Form\Flow\AbstractButtonFlowType; +use Symfony\Component\Form\Flow\ButtonFlowInterface; +use Symfony\Component\Form\Flow\FormFlowCursor; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class NextFlowType extends AbstractButtonFlowType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->setAttribute('action', 'next'); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'handler' => fn (mixed $data, ButtonFlowInterface $button, FormFlowInterface $flow) => $flow->moveNext(), + 'include_if' => fn (FormFlowCursor $cursor): bool => $cursor->canMoveNext(), + ]); + } +} diff --git a/Flow/Type/PreviousFlowType.php b/Flow/Type/PreviousFlowType.php new file mode 100644 index 00000000..2c3f9aac --- /dev/null +++ b/Flow/Type/PreviousFlowType.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\Flow\Type; + +use Symfony\Component\Form\Flow\AbstractButtonFlowType; +use Symfony\Component\Form\Flow\ButtonFlowInterface; +use Symfony\Component\Form\Flow\FormFlowCursor; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class PreviousFlowType extends AbstractButtonFlowType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->setAttribute('action', 'previous'); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'handler' => fn (mixed $data, ButtonFlowInterface $button, FormFlowInterface $flow) => $flow->movePrevious($button->getViewData()), + 'include_if' => fn (FormFlowCursor $cursor): bool => $cursor->canMoveBack(), + 'clear_submission' => true, + ]); + } +} diff --git a/Flow/Type/ResetFlowType.php b/Flow/Type/ResetFlowType.php new file mode 100644 index 00000000..2acc6f41 --- /dev/null +++ b/Flow/Type/ResetFlowType.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\Flow\Type; + +use Symfony\Component\Form\Flow\AbstractButtonFlowType; +use Symfony\Component\Form\Flow\ButtonFlowInterface; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ResetFlowType extends AbstractButtonFlowType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->setAttribute('action', 'reset'); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'handler' => fn (mixed $data, ButtonFlowInterface $button, FormFlowInterface $flow) => $flow->reset(), + 'clear_submission' => true, + ]); + } +} diff --git a/FormFactory.php b/FormFactory.php index dcf7b36f..48844035 100644 --- a/FormFactory.php +++ b/FormFactory.php @@ -13,6 +13,9 @@ namespace Symfony\Component\Form; 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 @@ class FormFactory implements FormFactoryInterface ) { } + /** + * @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 @@ class FormFactory implements FormFactoryInterface 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 @@ class FormFactory implements FormFactoryInterface $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/FormFactoryInterface.php b/FormFactoryInterface.php index 0f311c0e..e8c72c07 100644 --- a/FormFactoryInterface.php +++ b/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 @@ 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 createNamed(string $name, string $type = FormType::class, mixed $data = null, array $options = []): FormInterface; @@ -61,6 +68,8 @@ interface FormFactoryInterface * * @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 @@ interface FormFactoryInterface * * @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/ResolvedFormType.php b/ResolvedFormType.php index e2c51864..220905df 100644 --- a/ResolvedFormType.php +++ b/ResolvedFormType.php @@ -13,6 +13,10 @@ namespace Symfony\Component\Form; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Form\Exception\UnexpectedTypeException; +use Symfony\Component\Form\Flow\ButtonFlowBuilder; +use Symfony\Component\Form\Flow\ButtonFlowTypeInterface; +use Symfony\Component\Form\Flow\FormFlowBuilder; +use Symfony\Component\Form\Flow\FormFlowTypeInterface; use Symfony\Component\OptionsResolver\Exception\ExceptionInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -156,6 +160,14 @@ class ResolvedFormType implements ResolvedFormTypeInterface return new SubmitButtonBuilder($name, $options); } + if ($this->innerType instanceof ButtonFlowTypeInterface) { + return new ButtonFlowBuilder($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/Test/FormInterface.php b/Test/FormInterface.php index 4af46030..90248227 100644 --- a/Test/FormInterface.php +++ b/Test/FormInterface.php @@ -13,6 +13,9 @@ namespace Symfony\Component\Form\Test; use Symfony\Component\Form\FormInterface as BaseFormInterface; +/** + * @extends \Iterator + */ interface FormInterface extends \Iterator, BaseFormInterface { } diff --git a/Tests/Fixtures/Flow/Data/UserSignUp.php b/Tests/Fixtures/Flow/Data/UserSignUp.php new file mode 100644 index 00000000..9b14701b --- /dev/null +++ b/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/Tests/Fixtures/Flow/Extension/UserSignUpTypeExtension.php b/Tests/Fixtures/Flow/Extension/UserSignUpTypeExtension.php new file mode 100644 index 00000000..2596b27e --- /dev/null +++ b/Tests/Fixtures/Flow/Extension/UserSignUpTypeExtension.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\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 + { + if (!$builder instanceof FormFlowBuilderInterface) { + throw new \InvalidArgumentException(\sprintf('The "%s" can only be used with FormFlowType.', self::class)); + } + + $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/Tests/Fixtures/Flow/Step/UserSignUpAccountType.php b/Tests/Fixtures/Flow/Step/UserSignUpAccountType.php new file mode 100644 index 00000000..cd6f9c5a --- /dev/null +++ b/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/Tests/Fixtures/Flow/Step/UserSignUpPersonalType.php b/Tests/Fixtures/Flow/Step/UserSignUpPersonalType.php new file mode 100644 index 00000000..3132eb6c --- /dev/null +++ b/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/Tests/Fixtures/Flow/Step/UserSignUpProfessionalType.php b/Tests/Fixtures/Flow/Step/UserSignUpProfessionalType.php new file mode 100644 index 00000000..50f9b48b --- /dev/null +++ b/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/Tests/Fixtures/Flow/UserSignUpNavigatorType.php b/Tests/Fixtures/Flow/UserSignUpNavigatorType.php new file mode 100644 index 00000000..ed1bc31d --- /dev/null +++ b/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\NavigatorFlowType; +use Symfony\Component\Form\Flow\Type\NextFlowType; +use Symfony\Component\Form\Flow\Type\ResetFlowType; +use Symfony\Component\Form\FormBuilderInterface; + +class UserSignUpNavigatorType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('skip', NextFlowType::class, [ + 'clear_submission' => true, + 'include_if' => ['professional'], + ]); + + $builder->add('reset', ResetFlowType::class); + } + + public function getParent(): string + { + return NavigatorFlowType::class; + } +} diff --git a/Tests/Fixtures/Flow/UserSignUpType.php b/Tests/Fixtures/Flow/UserSignUpType.php new file mode 100644 index 00000000..119d163d --- /dev/null +++ b/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/Tests/Flow/FormFlowBuilderTest.php b/Tests/Flow/FormFlowBuilderTest.php new file mode 100644 index 00000000..7b5ee109 --- /dev/null +++ b/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/Tests/Flow/FormFlowCursorTest.php b/Tests/Flow/FormFlowCursorTest.php new file mode 100644 index 00000000..9db54825 --- /dev/null +++ b/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\FormFlowCursor; + +class FormFlowCursorTest extends TestCase +{ + private static array $steps = ['personal', 'professional', 'account']; + + public function testConstructorWithValidStep() + { + $cursor = new FormFlowCursor(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 FormFlowCursor(self::$steps, 'invalid'); + } + + public function testGetSteps() + { + $cursor = new FormFlowCursor(self::$steps, 'personal'); + + $this->assertSame(self::$steps, $cursor->getSteps()); + } + + public function testGetTotalSteps() + { + $cursor = new FormFlowCursor(self::$steps, 'personal'); + + $this->assertSame(3, $cursor->getTotalSteps()); + } + + public function testGetStepIndex() + { + $cursor = new FormFlowCursor(self::$steps, 'personal'); + $this->assertSame(0, $cursor->getStepIndex()); + + $cursor = new FormFlowCursor(self::$steps, 'professional'); + $this->assertSame(1, $cursor->getStepIndex()); + + $cursor = new FormFlowCursor(self::$steps, 'account'); + $this->assertSame(2, $cursor->getStepIndex()); + } + + public function testGetFirstStep() + { + $cursor = new FormFlowCursor(self::$steps, 'professional'); + + $this->assertSame('personal', $cursor->getFirstStep()); + } + + public function testGetPrevStep() + { + // First step has no previous step + $cursor = new FormFlowCursor(self::$steps, 'personal'); + $this->assertNull($cursor->getPreviousStep()); + + // Middle step has previous step + $cursor = new FormFlowCursor(self::$steps, 'professional'); + $this->assertSame('personal', $cursor->getPreviousStep()); + + // Last step has previous step + $cursor = new FormFlowCursor(self::$steps, 'account'); + $this->assertSame('professional', $cursor->getPreviousStep()); + } + + public function testGetCurrentStep() + { + $cursor = new FormFlowCursor(self::$steps, 'professional'); + + $this->assertSame('professional', $cursor->getCurrentStep()); + } + + public function testWithCurrentStep() + { + $cursor = new FormFlowCursor(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 FormFlowCursor(self::$steps, 'personal'); + $this->assertSame('professional', $cursor->getNextStep()); + + // Middle step has next step + $cursor = new FormFlowCursor(self::$steps, 'professional'); + $this->assertSame('account', $cursor->getNextStep()); + + // Last step has no next step + $cursor = new FormFlowCursor(self::$steps, 'account'); + $this->assertNull($cursor->getNextStep()); + } + + public function testGetLastStep() + { + $cursor = new FormFlowCursor(self::$steps, 'personal'); + + $this->assertSame('account', $cursor->getLastStep()); + } + + public function testIsFirstStep() + { + // First step + $cursor = new FormFlowCursor(self::$steps, 'personal'); + $this->assertTrue($cursor->isFirstStep()); + + // Not first step + $cursor = new FormFlowCursor(self::$steps, 'professional'); + $this->assertFalse($cursor->isFirstStep()); + } + + public function testIsLastStep() + { + // Not last step + $cursor = new FormFlowCursor(self::$steps, 'personal'); + $this->assertFalse($cursor->isLastStep()); + + // Last step + $cursor = new FormFlowCursor(self::$steps, 'account'); + $this->assertTrue($cursor->isLastStep()); + } + + public function testCanMovePreviousStep() + { + // First position cannot move a previous step + $cursor = new FormFlowCursor(self::$steps, 'personal'); + $this->assertFalse($cursor->canMoveBack()); + + // Middle position can move a previous step + $cursor = new FormFlowCursor(self::$steps, 'professional'); + $this->assertTrue($cursor->canMoveBack()); + + // Last step can move a previous step + $cursor = new FormFlowCursor(self::$steps, 'account'); + $this->assertTrue($cursor->canMoveBack()); + } + + public function testCanMoveNext() + { + // First position can move next step + $cursor = new FormFlowCursor(self::$steps, 'personal'); + $this->assertTrue($cursor->canMoveNext()); + + // Middle position can move next step + $cursor = new FormFlowCursor(self::$steps, 'professional'); + $this->assertTrue($cursor->canMoveNext()); + + // Last position cannot move the next step + $cursor = new FormFlowCursor(self::$steps, 'account'); + $this->assertFalse($cursor->canMoveNext()); + } + + public function testCursorWithSingleStep() + { + $steps = ['single']; + $cursor = new FormFlowCursor($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/Tests/Flow/FormFlowTest.php b/Tests/Flow/FormFlowTest.php new file mode 100644 index 00000000..7ed3fb0d --- /dev/null +++ b/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\ButtonFlowInterface; +use Symfony\Component\Form\Flow\DataStorage\InMemoryDataStorage; +use Symfony\Component\Form\Flow\FormFlowCursor; +use Symfony\Component\Form\Flow\FormFlowInterface; +use Symfony\Component\Form\Flow\Type\NextFlowType; +use Symfony\Component\Form\Flow\Type\PreviousFlowType; +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', PreviousFlowType::class, [ + 'validate' => false, + 'validation_groups' => false, + 'clear_submission' => false, + 'include_if' => fn (FormFlowCursor $cursor) => $cursor->canMoveBack(), + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('professional')); + + $flow->submit([ + 'professional' => [ + 'company' => 'Acme', + 'role' => 'ROLE_DEVELOPER', + ], + 'navigator' => [ + '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', PreviousFlowType::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('StepFlowBuilder methods cannot be accessed anymore once the builder is turned into a StepFlowConfigInterface 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(FormFlowCursor::class, $view->vars['cursor']); + self::assertCount(3, $view->vars['steps']); + self::assertSame(['personal', 'professional', 'account'], array_keys($view->vars['steps'])); + self::assertSame('personal', $view->vars['steps']['personal']['name']); + self::assertTrue($view->vars['steps']['personal']['is_current_step']); + self::assertFalse($view->vars['steps']['personal']['is_skipped']); + self::assertSame('professional', $view->vars['steps']['professional']['name']); + self::assertFalse($view->vars['steps']['professional']['is_current_step']); + self::assertTrue($view->vars['steps']['professional']['is_skipped']); + self::assertSame('account', $view->vars['steps']['account']['name']); + self::assertFalse($view->vars['steps']['account']['is_current_step']); + self::assertFalse($view->vars['steps']['account']['is_skipped']); + } + + public function testFallbackCurrentStep() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + + /** @var UserSignUp $data */ + $data = $flow->getViewData(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep(), 'The current step should be the first one depending on the step priority'); + self::assertSame('personal', $data->currentStep); + } + + public function testInitialCurrentStep() + { + $data = new UserSignUp(); + $data->currentStep = 'professional'; + $flow = $this->factory->create(UserSignUpType::class, $data); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep(), 'The current step should be the one set in the initial data'); + self::assertSame('professional', $data->currentStep); + } + + public function testFormFlowWithArrayData() + { + $flow = $this->factory->create(UserSignUpType::class, [], [ + 'data_class' => null, + 'step_property_path' => '[currentStep]', + ]); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertFalse($flow->isSubmitted()); + self::assertNull($flow->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', NextFlowType::class, [ + 'handler' => function (mixed $data, ButtonFlowInterface $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)); + } +}