Add FormFlow for multistep forms management

This commit is contained in:
Yonel Ceruto
2025-03-18 12:12:45 -04:00
parent 99061631c4
commit 4a374b6a6b
47 changed files with 3534 additions and 0 deletions

View File

@@ -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
---

View File

@@ -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),
];
}

View File

@@ -24,6 +24,7 @@ class HttpFoundationExtension extends AbstractExtension
{
return [
new Type\FormTypeHttpFoundationExtension(),
new Type\FormFlowTypeSessionDataStorageExtension(),
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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];
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
abstract class AbstractButtonFlowType extends AbstractType implements ButtonFlowTypeInterface
{
public function getParent(): string
{
return ButtonFlowType::class;
}
}

68
Flow/AbstractFlowType.php Normal file
View File

@@ -0,0 +1,68 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
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;
}
}

92
Flow/ButtonFlow.php Normal file
View File

@@ -0,0 +1,92 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
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');
}
}

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
class ButtonFlowBuilder extends ButtonBuilder
{
public function getForm(): ButtonFlow
{
return new ButtonFlow($this->getFormConfig());
}
}

View File

@@ -0,0 +1,56 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
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;
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
interface ButtonFlowTypeInterface extends FormTypeInterface
{
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
interface DataStorageInterface
{
public function save(object|array $data): void;
public function load(object|array|null $default = null): object|array|null;
public function clear(): void;
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
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]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
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
}
}

View File

@@ -0,0 +1,41 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
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);
}
}

243
Flow/FormFlow.php Normal file
View File

@@ -0,0 +1,243 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*
* @implements \IteratorAggregate<string, FormInterface>
*/
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;
}
}

255
Flow/FormFlowBuilder.php Normal file
View File

@@ -0,0 +1,255 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*
* @implements \IteratorAggregate<string, FormBuilderInterface>
*/
class FormFlowBuilder extends FormBuilder implements FormFlowBuilderInterface
{
/**
* @var array<string, StepFlowBuilderConfigInterface>
*/
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());
}
}
}
}

View File

@@ -0,0 +1,74 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*
* @extends \Traversable<string, FormBuilderInterface>
*/
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<string, StepFlowBuilderConfigInterface>
*/
public function getSteps(): array;
/**
* Sets the initial options for the form flow.
*
* @param array<string, mixed> $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;
}

View File

@@ -0,0 +1,68 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
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<string, StepFlowConfigInterface>
*/
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<string, mixed>
*/
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;
}

103
Flow/FormFlowCursor.php Normal file
View File

@@ -0,0 +1,103 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
class FormFlowCursor
{
/**
* @param array<string> $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();
}
}

View File

@@ -0,0 +1,74 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
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;
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $options
*/
public function finishViewFlow(FormView $view, FormFlowInterface $form, array $options): void;
}

View File

@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
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);
}
}

View File

@@ -0,0 +1,24 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
interface StepAccessorInterface
{
public function getStep(object|array $data, ?string $default = null): ?string;
public function setStep(object|array &$data, string $step): void;
}

112
Flow/StepFlowBuilder.php Normal file
View File

@@ -0,0 +1,112 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
class StepFlowBuilder implements StepFlowBuilderConfigInterface
{
private bool $locked = false;
private int $priority = 0;
private ?\Closure $skip = null;
/**
* @param class-string<FormTypeInterface> $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;
}
}

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
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;
}

View File

@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
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;
}

View File

@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
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;
}
}

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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(),
]);
}
}

129
Flow/Type/FormFlowType.php Normal file
View File

@@ -0,0 +1,129 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
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([]);
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <open@yceruto.dev>
*/
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,
]);
}
}

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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(),
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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,
]);
}
}

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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,
]);
}
}

View File

@@ -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<FormFlowTypeInterface> ? 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<FormFlowTypeInterface> ? 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<FormFlowTypeInterface> ? 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<FormFlowTypeInterface> ? 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());

View File

@@ -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<FormFlowTypeInterface> ? 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<FormFlowTypeInterface> ? 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<FormFlowTypeInterface> ? 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<FormFlowTypeInterface> ? 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;

View File

@@ -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);
}

View File

@@ -13,6 +13,9 @@ namespace Symfony\Component\Form\Test;
use Symfony\Component\Form\FormInterface as BaseFormInterface;
/**
* @extends \Iterator<string, BaseFormInterface>
*/
interface FormInterface extends \Iterator, BaseFormInterface
{
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 = '';
}

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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];
}
}

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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,
]);
}
}

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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,
]);
}
}

View File

@@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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,
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}
}

View File

@@ -0,0 +1,46 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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',
]);
}
}

View File

@@ -0,0 +1,92 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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();
}
}

View File

@@ -0,0 +1,202 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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());
}
}

935
Tests/Flow/FormFlowTest.php Normal file
View File

@@ -0,0 +1,935 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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));
}
}