diff --git a/CHANGELOG.md b/CHANGELOG.md index 75ba6ef..f89ddb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,20 @@ CHANGELOG ========= +0.3.0 +----- + * Add support for grouping and nested steps in `FormFlowType` + * Add `FlowStepNode` for representing the step flow tree (forest graph) + * Add `createStepGroup()` method to `FormFlowBuilderInterface` + * Add `setGroup()`, `addStep()`, `removeStep()` methods to `FlowStepBuilderInterface` + * Add `isGroup()`, `getSteps()`, `hasStep()`, `getStep()` methods to `FlowStepConfigInterface` + * Add `with_reset` option to `FlowNavigatorType` to conditionally include the reset button (defaults to `false`) + * Add `getStepIndexOf()`, `getParentStep()`, `getChildSteps()`, `getCurrentStepNode()`, `getStepNode()` and `getRootStepNodes()` methods to `FlowCursor` + * `FlowCursor` now accepts either a flat list of step names or a list of `FlowStepConfigInterface` instances, and flattens nested trees via DFS pre-order traversal + * Add new view variables to `FormFlowType`: `level`, `is_before_current_step`, `has_current_step_descendant`, `is_after_current_step`, `is_group`, `children`, `visible_children` + * [Breaking Changes] `FlowNavigatorType` no longer adds a `reset` button by default; pass `'with_reset' => true` to opt in + * [Breaking Changes] `FlowStepBuilder::setGroup()`, `addStep()`, `removeStep()` return type changed from `FlowStepBuilderInterface` to `static` + 0.2.3 ----- * Add `buildViewFlow()` and `finishViewFlow` methods to `FormFlowTypeInterface` diff --git a/src/Form/Flow/FlowCursor.php b/src/Form/Flow/FlowCursor.php index 003ba94..3edee74 100644 --- a/src/Form/Flow/FlowCursor.php +++ b/src/Form/Flow/FlowCursor.php @@ -3,21 +3,61 @@ namespace Yceruto\FormFlowBundle\Form\Flow; use Symfony\Component\Form\Exception\InvalidArgumentException; +use Symfony\Component\Form\Exception\LogicException; class FlowCursor { + /** @var list */ + private array $roots; + /** @var array */ + private array $stepMap; + /** @var list */ + private array $steps; + private FlowStepNode $currentStep; + /** - * @param array $steps + * @param array|list $steps Step configs or a flat list of step names + * @param string $currentStep The name of the current step */ public function __construct( - private readonly array $steps, - private readonly string $currentStep, + array $steps, + string $currentStep, ) { - if (!\in_array($currentStep, $steps, true)) { - throw new InvalidArgumentException(\sprintf('Step "%s" does not exist. Available steps are: "%s".', $currentStep, implode('", "', $steps))); + $first = reset($steps); + + if (\is_string($first)) { + $this->roots = FlowStepNode::fromArray($steps); + } elseif ($first instanceof FlowStepConfigInterface) { + $this->roots = FlowStepNode::fromConfig($steps); + } else { + throw new InvalidArgumentException('The $steps argument must be a list of step names or a list of step configs.'); + } + + // Performs a DFS pre-order traversal to build flat step names and node lookup map. + $this->stepMap = []; + $this->steps = []; + $stack = array_reverse($this->roots); + while ($stack) { + $node = array_pop($stack); + $this->stepMap[$node->getName()] = $node; + $this->steps[] = $node->getName(); + foreach (array_reverse($node->getChildren()) as $child) { + $stack[] = $child; + } } + + if (!isset($this->stepMap[$currentStep])) { + throw new InvalidArgumentException(\sprintf('Step "%s" does not exist. Available steps are: "%s".', $currentStep, implode('", "', $this->steps))); + } + + $this->currentStep = $this->stepMap[$currentStep]; } + /** + * Returns the flattened list of steps in DFS pre-order. + * + * @return list + */ public function getSteps(): array { return $this->steps; @@ -28,64 +68,152 @@ public function getTotalSteps(): int return \count($this->steps); } + /** + * Returns the global index of the current step among all flattened steps. + */ public function getStepIndex(): int { - return (int) array_search($this->currentStep, $this->steps, true); + return $this->getStepIndexOf($this->currentStep->getName()); + } + + public function getStepIndexOf(string $name): int + { + return (int) array_search($name, $this->steps, true); + } + + public function getParentStep(): ?string + { + return $this->currentStep->getParent()?->getName(); + } + + /** + * @return list + */ + public function getChildSteps(): array + { + return array_map(static fn (FlowStepNode $node) => $node->getName(), $this->currentStep->getChildren()); } public function getFirstStep(): string { - return $this->steps[0]; + foreach ($this->steps as $name) { + if (!$this->stepMap[$name]->isGroup()) { + return $name; + } + } + + throw new LogicException('No visitable step found.'); } public function getPreviousStep(): ?string { - $currentPos = array_search($this->currentStep, $this->steps, true); - - return $this->steps[$currentPos - 1] ?? null; + return $this->currentStep->getPreviousInTraversal()?->getName(); } public function getCurrentStep(): string { - return $this->currentStep; + return $this->currentStep->getName(); } public function withCurrentStep(string $step): self { - return new self($this->steps, $step); + if (!isset($this->stepMap[$step])) { + throw new InvalidArgumentException(\sprintf('Step "%s" does not exist. Available steps are: "%s".', $step, implode('", "', $this->steps))); + } + + $clone = clone $this; + $clone->currentStep = $this->stepMap[$step]; + + return $clone; } public function getNextStep(): ?string { - $currentPos = array_search($this->currentStep, $this->steps, true); - - return $this->steps[$currentPos + 1] ?? null; + return $this->currentStep->getNextInTraversal()?->getName(); } public function getLastStep(): string { - return $this->steps[\count($this->steps) - 1]; + for ($i = \count($this->steps) - 1; $i >= 0; --$i) { + if (!$this->stepMap[$this->steps[$i]]->isGroup()) { + return $this->steps[$i]; + } + } + + throw new LogicException('No visitable step found.'); } public function isFirstStep(): bool { - return 0 === array_search($this->currentStep, $this->steps, true); + $node = $this->currentStep; + while (null !== $prev = $node->getPreviousInTraversal()) { + if (!$prev->isGroup()) { + return false; + } + $node = $prev; + } + + return true; } public function isLastStep(): bool { - $currentPos = array_search($this->currentStep, $this->steps, true); + $node = $this->currentStep; + while (null !== $next = $node->getNextInTraversal()) { + if (!$next->isGroup()) { + return false; + } + $node = $next; + } - return \count($this->steps) === $currentPos + 1; + return true; } public function canMoveBack(): bool { - return null !== $this->getPreviousStep(); + $node = $this->currentStep; + while (null !== $prev = $node->getPreviousInTraversal()) { + if (!$prev->isGroup()) { + return true; + } + $node = $prev; + } + + return false; } public function canMoveNext(): bool { - return null !== $this->getNextStep(); + $node = $this->currentStep; + while (null !== $next = $node->getNextInTraversal()) { + if (!$next->isGroup()) { + return true; + } + $node = $next; + } + + return false; + } + + public function getCurrentStepNode(): FlowStepNode + { + return $this->currentStep; + } + + public function getStepNode(string $name): FlowStepNode + { + if (!isset($this->stepMap[$name])) { + throw new InvalidArgumentException(\sprintf('Step "%s" does not exist. Available steps are: "%s".', $name, implode('", "', $this->steps))); + } + + return $this->stepMap[$name]; + } + + /** + * @return list + */ + public function getRootStepNodes(): array + { + return $this->roots; } } diff --git a/src/Form/Flow/FlowStepBuilder.php b/src/Form/Flow/FlowStepBuilder.php index d9a82cf..326ab60 100644 --- a/src/Form/Flow/FlowStepBuilder.php +++ b/src/Form/Flow/FlowStepBuilder.php @@ -3,6 +3,8 @@ namespace Yceruto\FormFlowBundle\Form\Flow; use Symfony\Component\Form\Exception\BadMethodCallException; +use Symfony\Component\Form\Exception\InvalidArgumentException; +use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\FormTypeInterface; class FlowStepBuilder implements FlowStepBuilderInterface @@ -10,13 +12,16 @@ class FlowStepBuilder implements FlowStepBuilderInterface private bool $locked = false; private int $priority = 0; private ?\Closure $skip = null; + /** @var array */ + private array $children = []; + private bool $group = false; /** * @param class-string $type */ public function __construct( private readonly string $name, - private readonly string $type, + private readonly string $type = FormType::class, private readonly array $options = [], ) { } @@ -85,6 +90,85 @@ public function setSkip(?\Closure $skip): static return $this; } + public function setGroup(bool $group): static + { + if ($this->locked) { + throw new BadMethodCallException('FlowStepBuilder methods cannot be accessed anymore once the builder is turned into a FlowStepConfigInterface instance.'); + } + + $this->group = $group; + + return $this; + } + + public function isGroup(): bool + { + return $this->group; + } + + public function addStep(FlowStepBuilderInterface|string $name, string $type = FormType::class, array $options = [], ?callable $skip = null, int $priority = 0): static + { + if ($this->locked) { + throw new BadMethodCallException('FlowStepBuilder methods cannot be accessed anymore once the builder is turned into a FlowStepConfigInterface instance.'); + } + + if ($name instanceof FlowStepBuilderInterface) { + $this->children[$name->getName()] = $name; + + return $this; + } + + $this->children[$name] = (new FlowStepBuilder($name, $type, $options)) + ->setSkip($skip ? $skip(...) : null) + ->setPriority($priority); + + return $this; + } + + public function removeStep(string $name): static + { + unset($this->children[$name]); + + return $this; + } + + public function getSteps(): array + { + return $this->children; + } + + public function hasStep(string $name): bool + { + if (isset($this->children[$name])) { + return true; + } + + foreach ($this->children as $step) { + if ($step->hasStep($name)) { + return true; + } + } + + return false; + } + + public function getStep(string $name): FlowStepConfigInterface + { + if (isset($this->children[$name])) { + return $this->children[$name]; + } + + foreach ($this->children as $step) { + try { + return $step->getStep($name); + } catch (InvalidArgumentException) { + // Continue searching + } + } + + throw new InvalidArgumentException(\sprintf('Sub step "%s" does not exist in "%s" step.', $name, $this->name)); + } + public function getStepConfig(): FlowStepConfigInterface { if ($this->locked) { @@ -95,6 +179,12 @@ public function getStepConfig(): FlowStepConfigInterface $config = clone $this; $config->locked = true; + uasort($config->children, static fn (FlowStepBuilderInterface $a, FlowStepBuilderInterface $b) => $b->getPriority() <=> $a->getPriority()); + + foreach ($config->children as $name => $step) { + $config->children[$name] = $step->getStepConfig(); + } + return $config; } } diff --git a/src/Form/Flow/FlowStepBuilderInterface.php b/src/Form/Flow/FlowStepBuilderInterface.php index 7113697..605d5b4 100644 --- a/src/Form/Flow/FlowStepBuilderInterface.php +++ b/src/Form/Flow/FlowStepBuilderInterface.php @@ -2,6 +2,8 @@ namespace Yceruto\FormFlowBundle\Form\Flow; +use Symfony\Component\Form\Extension\Core\Type\FormType; + interface FlowStepBuilderInterface extends FlowStepConfigInterface { /** @@ -29,6 +31,21 @@ public function setPriority(int $priority): static; */ public function setSkip(?\Closure $skip): static; + /** + * Marks (or unmarks) this step as a group (a non-navigable container with child steps). + */ + public function setGroup(bool $group): static; + + /** + * Adds a child step. + */ + public function addStep(self|string $name, string $type = FormType::class, array $options = [], ?callable $skip = null, int $priority = 0): static; + + /** + * Removes a child step by name. + */ + public function removeStep(string $name): static; + /** * Returns a FlowStepConfigInterface instance for the step. */ diff --git a/src/Form/Flow/FlowStepConfigInterface.php b/src/Form/Flow/FlowStepConfigInterface.php index cb08740..90879b1 100644 --- a/src/Form/Flow/FlowStepConfigInterface.php +++ b/src/Form/Flow/FlowStepConfigInterface.php @@ -18,4 +18,26 @@ public function getSkip(): ?\Closure; * Determines if the step should be skipped based on the provided data. */ public function isSkipped(mixed $data): bool; + + /** + * Whether this step is a group (a container with child steps but not navigable itself). + */ + public function isGroup(): bool; + + /** + * Returns the child step configurations. + * + * @return array + */ + public function getSteps(): array; + + /** + * Whether a child step with the given name exists (recursively). + */ + public function hasStep(string $name): bool; + + /** + * Returns the child step configuration with the given name (recursively). + */ + public function getStep(string $name): self; } diff --git a/src/Form/Flow/FlowStepNode.php b/src/Form/Flow/FlowStepNode.php new file mode 100644 index 0000000..68c80f9 --- /dev/null +++ b/src/Form/Flow/FlowStepNode.php @@ -0,0 +1,200 @@ + */ + private array $children = []; + private ?self $previousSibling = null; + private ?self $nextSibling = null; + + private function __construct( + private readonly string $name, + private readonly ?\Closure $skip = null, + private readonly bool $group = false, + private readonly ?self $parent = null, + ) { + } + + /** + * @param list $steps + * + * @return list + */ + public static function fromArray(array $steps): array + { + $nodes = []; + $previous = null; + + foreach ($steps as $name) { + $node = new self($name); + + if ($previous) { + $previous->nextSibling = $node; + $node->previousSibling = $previous; + } + + $nodes[] = $node; + $previous = $node; + } + + return $nodes; + } + + /** + * Builds a forest from step configurations. + * + * @param array $steps Ordered step configs + * + * @return list + */ + public static function fromConfig(array $steps, ?self $parent = null): array + { + $nodes = []; + $previous = null; + + foreach ($steps as $name => $step) { + $node = new self($name, $step->getSkip(), $step->isGroup(), $parent); + + if ($previous) { + $previous->nextSibling = $node; + $node->previousSibling = $previous; + } + + if ($children = $step->getSteps()) { + $node->children = self::fromConfig($children, $node); + } elseif ($node->group) { + throw new LogicException(\sprintf('Step "%s" is marked as group but has no child steps.', $name)); + } + + $nodes[] = $node; + $previous = $node; + } + + return $nodes; + } + + public function getName(): string + { + return $this->name; + } + + public function getParent(): ?self + { + return $this->parent; + } + + /** + * @return list + */ + public function getChildren(): array + { + return $this->children; + } + + public function getNextSibling(): ?self + { + return $this->nextSibling; + } + + public function getPreviousSibling(): ?self + { + return $this->previousSibling; + } + + public function isGroup(): bool + { + return $this->group; + } + + public function getSkip(): ?\Closure + { + return $this->skip; + } + + public function isGroupOrSkipped(mixed $data): bool + { + if ($this->group) { + return true; + } + + if ($this->skip) { + return ($this->skip)($data); + } + + // Check ancestors: if a parent is skipped via skip func, children are too + $ancestor = $this->parent; + while ($ancestor) { + if ($ancestor->skip && ($ancestor->skip)($data)) { + return true; + } + $ancestor = $ancestor->parent; + } + + return false; + } + + /** + * Returns the next node in DFS pre-order traversal. + * + * If this node has children, returns the first child. + * Otherwise returns the next sibling, or walks up to + * find an ancestor with a next sibling. + */ + public function getNextInTraversal(): ?self + { + if ($this->children) { + return $this->children[0]; + } + + if ($this->nextSibling) { + return $this->nextSibling; + } + + $ancestor = $this->parent; + while ($ancestor) { + if ($ancestor->nextSibling) { + return $ancestor->nextSibling; + } + $ancestor = $ancestor->parent; + } + + return null; + } + + /** + * Returns the previous node in DFS pre-order traversal. + * + * If this node has a previous sibling, returns that sibling's + * deepest last descendant. Otherwise returns the parent. + */ + public function getPreviousInTraversal(): ?self + { + if ($this->previousSibling) { + return $this->previousSibling->getDeepestLastDescendant(); + } + + return $this->parent; + } + + /** + * Returns the deepest last descendant of this node. + * + * Recursively follows the last child until a leaf node is reached. + * Returns self if this node has no children. + */ + private function getDeepestLastDescendant(): self + { + if (!$this->children) { + return $this; + } + + return $this->children[\count($this->children) - 1]->getDeepestLastDescendant(); + } +} diff --git a/src/Form/Flow/FormFlow.php b/src/Form/Flow/FormFlow.php index 50a325e..0806881 100644 --- a/src/Form/Flow/FormFlow.php +++ b/src/Form/Flow/FormFlow.php @@ -74,14 +74,14 @@ public function movePrevious(?string $step = null): void return; } - if (!$this->move(fn (FlowCursor $cursor) => $cursor->getPreviousStep())) { + if (!$this->move(static fn (FlowCursor $cursor) => $cursor->getPreviousStep())) { throw new RuntimeException('Cannot determine previous step.'); } } public function moveNext(): void { - if (!$this->move(fn (FlowCursor $cursor) => $cursor->getNextStep())) { + if (!$this->move(static fn (FlowCursor $cursor) => $cursor->getNextStep())) { throw new RuntimeException('Cannot determine next step.'); } } @@ -166,7 +166,7 @@ private function moveBackTo(string $step): void { $steps = $this->cursor->getSteps(); - if (false === $targetIndex = array_search($step, $steps)) { + if (false === $targetIndex = array_search($step, $steps, true)) { throw new InvalidArgumentException(\sprintf('Step "%s" does not exist.', $step)); } @@ -191,6 +191,9 @@ private function moveBackTo(string $step): void } } + /** + * @param-immediately-invoked-callable $direction + */ private function move(\Closure $direction): bool { $data = $this->getData(); @@ -207,9 +210,19 @@ private function move(\Closure $direction): bool $cursor = $cursor->withCurrentStep($newStep); - if (!$this->config->getStep($newStep)->isSkipped($data)) { + if (!$cursor->getCurrentStepNode()->isGroupOrSkipped($data)) { break; } + + if ($cursor->isLastStep()) { + $this->finished = true; + + if ($this->config->isAutoReset()) { + $this->reset(); + } + + return true; + } } $this->cursor = $cursor; diff --git a/src/Form/Flow/FormFlowBuilder.php b/src/Form/Flow/FormFlowBuilder.php index 3509ed2..fd8478d 100644 --- a/src/Form/Flow/FormFlowBuilder.php +++ b/src/Form/Flow/FormFlowBuilder.php @@ -26,6 +26,15 @@ class FormFlowBuilder extends FormBuilder implements FormFlowBuilderInterface private DataStorageInterface $dataStorage; private StepAccessorInterface $stepAccessor; + public function createStepGroup(string $name): FlowStepBuilderInterface + { + if ($this->locked) { + throw new BadMethodCallException('FormFlowBuilder methods cannot be accessed anymore once the builder is turned into a FormFlowConfigInterface instance.'); + } + + return (new FlowStepBuilder($name))->setGroup(true); + } + public function createStep(string $name, string $type = FormType::class, array $options = []): FlowStepBuilderInterface { if ($this->locked) { @@ -68,12 +77,34 @@ public function removeStep(string $name): static public function hasStep(string $name): bool { - return isset($this->steps[$name]); + if (isset($this->steps[$name])) { + return true; + } + + foreach ($this->steps as $step) { + if ($step->hasStep($name)) { + return true; + } + } + + return false; } public function getStep(string $name): FlowStepBuilderInterface { - return $this->steps[$name] ?? throw new InvalidArgumentException(\sprintf('Step "%s" does not exist.', $name)); + if (isset($this->steps[$name])) { + return $this->steps[$name]; + } + + foreach ($this->steps as $step) { + try { + return $step->getStep($name); + } catch (InvalidArgumentException) { + // Continue searching + } + } + + throw new InvalidArgumentException(\sprintf('Step "%s" does not exist.', $name)); } public function getSteps(): array @@ -94,7 +125,7 @@ public function setInitialOptions(array $options): static public function getInitialStep(): string { - $defaultStep = (string) key($this->steps); + $defaultStep = $this->resolveFirstStep(); if (!isset($this->initialOptions['data'])) { return $defaultStep; @@ -191,23 +222,18 @@ private function createFormFlow(): FormFlowInterface throw new InvalidArgumentException('Steps not configured.'); } - uasort($this->steps, static function (FlowStepBuilderInterface $a, FlowStepBuilderInterface $b) { - return $b->getPriority() <=> $a->getPriority(); - }); + uasort($this->steps, static fn (FlowStepBuilderInterface $a, FlowStepBuilderInterface $b) => $b->getPriority() <=> $a->getPriority()); + $config = $this->getFormConfig(); $currentStep = $this->resolveCurrentStep(); - if (!isset($this->steps[$currentStep])) { - throw new InvalidArgumentException(\sprintf('Step form "%s" is not defined.', $currentStep)); - } - - $step = $this->steps[$currentStep]; + $step = $this->getStep($currentStep); $this->add($step->getName(), $step->getType(), $step->getOptions()); - $cursor = new FlowCursor(array_keys($this->steps), $currentStep); + $cursor = new FlowCursor($config->getSteps(), $currentStep); $this->pruneActionButtons($this, $cursor); - return new FormFlow($this->getFormConfig(), $cursor); + return new FormFlow($config, $cursor); } private function resolveCurrentStep(): string @@ -215,7 +241,7 @@ private function resolveCurrentStep(): string $data = $this->getData(); if (!$currentStep = $this->getStepAccessor()->getStep($data)) { - $currentStep = key($this->steps); + $currentStep = $this->resolveFirstStep(); $this->getStepAccessor()->setStep($data, $currentStep); $this->setData($data); } @@ -223,6 +249,30 @@ private function resolveCurrentStep(): string return $currentStep; } + /** + * Finds the first navigable step in DFS pre-order. + * + * A step is navigable if it is neither a group nor skipped. + */ + private function resolveFirstStep(?array $steps = null): string + { + foreach ($steps ?? $this->steps as $step) { + if (!$step->isGroup() && !$step->isSkipped($this->getData())) { + return $step->getName(); + } + + if ($children = $step->getSteps()) { + try { + return $this->resolveFirstStep($children); + } catch (LogicException) { + continue; + } + } + } + + throw new LogicException('No navigable step found. All steps are groups or skipped.'); + } + private function pruneActionButtons(FormBuilderInterface $builder, FlowCursor $cursor): void { foreach ($builder->all() as $child) { diff --git a/src/Form/Flow/FormFlowBuilderInterface.php b/src/Form/Flow/FormFlowBuilderInterface.php index ed05bd7..02fb3db 100644 --- a/src/Form/Flow/FormFlowBuilderInterface.php +++ b/src/Form/Flow/FormFlowBuilderInterface.php @@ -12,6 +12,11 @@ */ interface FormFlowBuilderInterface extends FormBuilderInterface, FormFlowConfigInterface { + /** + * Creates a new step builder marked as a group. + */ + public function createStepGroup(string $name): FlowStepBuilderInterface; + /** * Creates a new step builder. */ diff --git a/src/Form/Flow/Type/FlowNavigatorType.php b/src/Form/Flow/Type/FlowNavigatorType.php index fcb12d5..e39cd34 100644 --- a/src/Form/Flow/Type/FlowNavigatorType.php +++ b/src/Form/Flow/Type/FlowNavigatorType.php @@ -16,6 +16,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder->add('previous', FlowPreviousType::class); $builder->add('next', FlowNextType::class); $builder->add('finish', FlowFinishType::class); + + if ($options['with_reset']) { + $builder->add('reset', FlowResetType::class); + } } public function configureOptions(OptionsResolver $resolver): void @@ -25,5 +29,10 @@ public function configureOptions(OptionsResolver $resolver): void 'mapped' => false, 'priority' => -100, ]); + + $resolver->define('with_reset') + ->allowedTypes('bool') + ->default(false) + ->info('Whether to add a reset button to restart the flow from the first step'); } } diff --git a/src/Form/Flow/Type/FormFlowType.php b/src/Form/Flow/Type/FormFlowType.php index 4483030..7b2968c 100644 --- a/src/Form/Flow/Type/FormFlowType.php +++ b/src/Form/Flow/Type/FormFlowType.php @@ -17,6 +17,8 @@ use Yceruto\FormFlowBundle\Form\Flow\DataStorage\DataStorageInterface; use Yceruto\FormFlowBundle\Form\Flow\DataStorage\NullDataStorage; use Yceruto\FormFlowBundle\Form\Flow\FlowButtonInterface; +use Yceruto\FormFlowBundle\Form\Flow\FlowCursor; +use Yceruto\FormFlowBundle\Form\Flow\FlowStepConfigInterface; use Yceruto\FormFlowBundle\Form\Flow\FormFlowBuilderInterface; use Yceruto\FormFlowBundle\Form\Flow\FormFlowInterface; use Yceruto\FormFlowBundle\Form\Flow\StepAccessor\PropertyPathStepAccessor; @@ -44,27 +46,8 @@ public function buildFormFlow(FormFlowBuilderInterface $builder, array $options) 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; - } - } + $view->vars['steps'] = $this->buildStepsVars($form->getConfig()->getSteps(), $cursor, $form->getViewData()); + $view->vars['visible_steps'] = array_filter($view->vars['steps'], static fn ($step) => !$step['is_skipped']); } public function configureOptions(OptionsResolver $resolver): void @@ -86,18 +69,14 @@ public function configureOptions(OptionsResolver $resolver): void $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; - }); + ->normalize(static fn (Options $options, string|PropertyPathInterface $value): PropertyPathInterface => \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()]; - }); + $resolver->setDefault('validation_groups', static fn (FormFlowInterface $flow) => ['Default', $flow->getCursor()->getCurrentStep()]); } public function getParent(): string @@ -115,4 +94,55 @@ public function onPreSubmit(FormEvent $event): void $event->setData([]); } } + + /** + * @param array $steps + * + * @return array> + */ + private function buildStepsVars(array $steps, FlowCursor $cursor, mixed $viewData, int $level = 0): array + { + $tree = []; + $index = 0; + $position = 1; + $currentStep = $cursor->getCurrentStep(); + + foreach ($steps as $name => $step) { + $children = []; + if ($childSteps = $step->getSteps()) { + $children = $this->buildStepsVars($childSteps, $cursor, $viewData, $level + 1); + } + + $isSkipped = $step->isSkipped($viewData); + + $tree[$name] = [ + 'name' => $name, + 'level' => $level, + 'index' => $index++, + 'position' => $isSkipped ? -1 : $position++, + 'is_before_current_step' => $cursor->getStepIndexOf($name) < $cursor->getStepIndex(), + 'is_current_step' => $name === $currentStep, + 'has_current_step_descendant' => $this->hasCurrentStepDescendant($children), + 'is_after_current_step' => $cursor->getStepIndexOf($name) > $cursor->getStepIndex(), + 'can_be_skipped' => null !== $step->getSkip(), + 'is_skipped' => $isSkipped, + 'is_group' => $step->isGroup(), + 'children' => $children, + 'visible_children' => array_filter($children, static fn (array $child): bool => !$child['is_skipped']), + ]; + } + + return $tree; + } + + private function hasCurrentStepDescendant(array $children): bool + { + foreach ($children as $child) { + if ($child['is_current_step'] || $child['has_current_step_descendant']) { + return true; + } + } + + return false; + } } diff --git a/tests/Fixtures/Flow/Data/UserSignUp.php b/tests/Fixtures/Flow/Data/UserSignUp.php new file mode 100644 index 0000000..fd09ac8 --- /dev/null +++ b/tests/Fixtures/Flow/Data/UserSignUp.php @@ -0,0 +1,31 @@ +addStep('first', FormType::class, ['mapped' => false], null, 1); + $builder->addStep('last', FormType::class, ['mapped' => false]); + } + + public static function getExtendedTypes(): iterable + { + return [UserSignUpType::class]; + } +} diff --git a/tests/Fixtures/Flow/FirstStepSkippedType.php b/tests/Fixtures/Flow/FirstStepSkippedType.php new file mode 100644 index 0000000..2246ac1 --- /dev/null +++ b/tests/Fixtures/Flow/FirstStepSkippedType.php @@ -0,0 +1,29 @@ +addStep('step1', TextType::class, skip: static fn () => true); + $builder->addStep('step2', TextType::class); + $builder->addStep('step3', TextType::class); + + $builder->add('navigator', FlowNavigatorType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => null, + 'step_property_path' => '[currentStep]', + ]); + } +} diff --git a/tests/Fixtures/Flow/GroupingStepsFlowType.php b/tests/Fixtures/Flow/GroupingStepsFlowType.php new file mode 100644 index 0000000..a9eb536 --- /dev/null +++ b/tests/Fixtures/Flow/GroupingStepsFlowType.php @@ -0,0 +1,42 @@ + [a1, a2], b, c(group) => [c1]. + */ +class GroupingStepsFlowType extends AbstractFlowType +{ + public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void + { + $builder + ->addStep( + $builder->createStepGroup('a') + ->addStep('a1', TextType::class) + ->addStep('a2', TextType::class) + ) + ->addStep('b', TextType::class) + ->addStep( + $builder->createStepGroup('c') + ->addStep('c1', TextType::class) + ); + + $builder->add('navigator', FlowNavigatorType::class, ['with_reset' => true]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => null, + 'data_storage' => new InMemoryDataStorage('group_steps_flow'), + 'step_property_path' => '[currentStep]', + ]); + } +} diff --git a/tests/Fixtures/Flow/LastStepSkippedType.php b/tests/Fixtures/Flow/LastStepSkippedType.php new file mode 100644 index 0000000..2df34f7 --- /dev/null +++ b/tests/Fixtures/Flow/LastStepSkippedType.php @@ -0,0 +1,31 @@ +addStep('step1', TextType::class); + $builder->addStep('step2', FormType::class, [], static fn () => true); + + $builder->add('navigator', FlowNavigatorType::class, [ + 'with_reset' => true, + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => null, + 'step_property_path' => '[currentStep]', + ]); + } +} diff --git a/tests/Fixtures/Flow/NestedStepsFlowType.php b/tests/Fixtures/Flow/NestedStepsFlowType.php new file mode 100644 index 0000000..7f44c04 --- /dev/null +++ b/tests/Fixtures/Flow/NestedStepsFlowType.php @@ -0,0 +1,47 @@ +addStep( + $builder->createStepGroup('stepA') + ->addStep('stepA1', TextType::class) + ->addStep('stepA2', TextType::class) + ->addStep('stepA3', TextType::class) + ) + ->addStep( + $builder->createStep('stepB', ChoiceType::class, ['choices' => ['SkipB1' => 1, 'SkipB2' => 2]]) + ->addStep( + $builder->createStep('stepB1') + ->setSkip(fn (array $data) => 1 == ($data['stepB'] ?? null)) + ->addStep('stepB11', TextType::class) + ->addStep('stepB12', TextType::class) + ) + ->addStep('stepB2', TextType::class) + ) + ->addStep('stepC', TextType::class); + + $builder->add('navigator', FlowNavigatorType::class, ['with_reset' => true]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => null, + 'data_storage' => new InMemoryDataStorage('nested_steps_flow'), + 'step_property_path' => '[currentStep]', + ]); + } +} diff --git a/tests/Fixtures/Flow/Step/UserSignUpAccountType.php b/tests/Fixtures/Flow/Step/UserSignUpAccountType.php new file mode 100644 index 0000000..e0f18f5 --- /dev/null +++ b/tests/Fixtures/Flow/Step/UserSignUpAccountType.php @@ -0,0 +1,25 @@ +add('email', EmailType::class); + $builder->add('password', PasswordType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'inherit_data' => true, + ]); + } +} diff --git a/tests/Fixtures/Flow/Step/UserSignUpPersonalType.php b/tests/Fixtures/Flow/Step/UserSignUpPersonalType.php new file mode 100644 index 0000000..7ff72db --- /dev/null +++ b/tests/Fixtures/Flow/Step/UserSignUpPersonalType.php @@ -0,0 +1,26 @@ +add('firstName', TextType::class); + $builder->add('lastName', TextType::class); + $builder->add('worker', CheckboxType::class, ['required' => false]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'inherit_data' => true, + ]); + } +} diff --git a/tests/Fixtures/Flow/Step/UserSignUpProfessionalType.php b/tests/Fixtures/Flow/Step/UserSignUpProfessionalType.php new file mode 100644 index 0000000..b336706 --- /dev/null +++ b/tests/Fixtures/Flow/Step/UserSignUpProfessionalType.php @@ -0,0 +1,30 @@ +add('company'); + $builder->add('role', ChoiceType::class, [ + 'choices' => [ + 'Product Manager' => 'ROLE_MANAGER', + 'Developer' => 'ROLE_DEVELOPER', + 'Designer' => 'ROLE_DESIGNER', + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'inherit_data' => true, + ]); + } +} diff --git a/tests/Fixtures/Flow/UserSignUpNavigatorType.php b/tests/Fixtures/Flow/UserSignUpNavigatorType.php new file mode 100644 index 0000000..ff66e4e --- /dev/null +++ b/tests/Fixtures/Flow/UserSignUpNavigatorType.php @@ -0,0 +1,32 @@ +add('skip', FlowNextType::class, [ + 'clear_submission' => true, + 'include_if' => ['professional'], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'with_reset' => true, + ]); + } + + public function getParent(): string + { + return FlowNavigatorType::class; + } +} diff --git a/tests/Fixtures/Flow/UserSignUpType.php b/tests/Fixtures/Flow/UserSignUpType.php new file mode 100644 index 0000000..7803600 --- /dev/null +++ b/tests/Fixtures/Flow/UserSignUpType.php @@ -0,0 +1,37 @@ + !$data->worker + : static fn (array $data) => !$data['worker']; + + $builder->addStep('personal', UserSignUpPersonalType::class); + $builder->addStep('professional', UserSignUpProfessionalType::class, [], $skip); + $builder->addStep('account', UserSignUpAccountType::class); + + $builder->add('navigator', UserSignUpNavigatorType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => UserSignUp::class, + 'data_storage' => new InMemoryDataStorage('user_sign_up'), + 'step_property_path' => 'currentStep', + ]); + } +} diff --git a/tests/Flow/FlowCursorTest.php b/tests/Flow/FlowCursorTest.php new file mode 100644 index 0000000..ad88229 --- /dev/null +++ b/tests/Flow/FlowCursorTest.php @@ -0,0 +1,478 @@ + $names + * + * @return array + */ + private static function createSteps(array $names = self::STEPS): array + { + $configs = []; + foreach ($names as $name) { + $configs[$name] = (new FlowStepBuilder($name))->getStepConfig(); + } + + return $configs; + } + + private static function createNestedSteps(): array + { + $personal = (new FlowStepBuilder('personal')) + ->addStep('name') + ->addStep('contact'); + + return [ + 'intro' => (new FlowStepBuilder('intro'))->getStepConfig(), + 'personal' => $personal->getStepConfig(), + 'summary' => (new FlowStepBuilder('summary'))->getStepConfig(), + ]; + } + + public function testConstructorWithValidStep() + { + $cursor = new FlowCursor(self::createSteps(), 'personal'); + + $this->assertSame(self::STEPS, $cursor->getSteps()); + $this->assertSame('personal', $cursor->getCurrentStep()); + } + + public function testConstructorWithInvalidStep() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Step "invalid" does not exist. Available steps are: "personal", "professional", "account".'); + + new FlowCursor(self::createSteps(), 'invalid'); + } + + public function testConstructorWithDeprecatedStringList() + { + $cursor = new FlowCursor(self::STEPS, 'personal'); + + $this->assertSame(self::STEPS, $cursor->getSteps()); + } + + public function testGetSteps() + { + $cursor = new FlowCursor(self::createSteps(), 'personal'); + + $this->assertSame(self::STEPS, $cursor->getSteps()); + } + + public function testGetTotalSteps() + { + $cursor = new FlowCursor(self::createSteps(), 'personal'); + + $this->assertSame(3, $cursor->getTotalSteps()); + } + + public function testGetStepIndex() + { + $steps = self::createSteps(); + + $cursor = new FlowCursor($steps, 'personal'); + $this->assertSame(0, $cursor->getStepIndex()); + + $cursor = new FlowCursor($steps, 'professional'); + $this->assertSame(1, $cursor->getStepIndex()); + + $cursor = new FlowCursor($steps, 'account'); + $this->assertSame(2, $cursor->getStepIndex()); + } + + public function testGetFirstStep() + { + $cursor = new FlowCursor(self::createSteps(), 'professional'); + + $this->assertSame('personal', $cursor->getFirstStep()); + } + + public function testGetPrevStep() + { + $steps = self::createSteps(); + + // First step has no previous step + $cursor = new FlowCursor($steps, 'personal'); + $this->assertNull($cursor->getPreviousStep()); + + // Middle step has previous step + $cursor = new FlowCursor($steps, 'professional'); + $this->assertSame('personal', $cursor->getPreviousStep()); + + // Last step has previous step + $cursor = new FlowCursor($steps, 'account'); + $this->assertSame('professional', $cursor->getPreviousStep()); + } + + public function testGetCurrentStep() + { + $cursor = new FlowCursor(self::createSteps(), 'professional'); + + $this->assertSame('professional', $cursor->getCurrentStep()); + } + + public function testWithCurrentStep() + { + $cursor = new FlowCursor(self::createSteps(), '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() + { + $steps = self::createSteps(); + + // First step has next step + $cursor = new FlowCursor($steps, 'personal'); + $this->assertSame('professional', $cursor->getNextStep()); + + // Middle step has next step + $cursor = new FlowCursor($steps, 'professional'); + $this->assertSame('account', $cursor->getNextStep()); + + // Last step has no next step + $cursor = new FlowCursor($steps, 'account'); + $this->assertNull($cursor->getNextStep()); + } + + public function testGetLastStep() + { + $cursor = new FlowCursor(self::createSteps(), 'personal'); + + $this->assertSame('account', $cursor->getLastStep()); + } + + public function testIsFirstStep() + { + $steps = self::createSteps(); + + // First step + $cursor = new FlowCursor($steps, 'personal'); + $this->assertTrue($cursor->isFirstStep()); + + // Not first step + $cursor = new FlowCursor($steps, 'professional'); + $this->assertFalse($cursor->isFirstStep()); + } + + public function testIsLastStep() + { + $steps = self::createSteps(); + + // Not last step + $cursor = new FlowCursor($steps, 'personal'); + $this->assertFalse($cursor->isLastStep()); + + // Last step + $cursor = new FlowCursor($steps, 'account'); + $this->assertTrue($cursor->isLastStep()); + } + + public function testCanMovePreviousStep() + { + $steps = self::createSteps(); + + // First position cannot move a previous step + $cursor = new FlowCursor($steps, 'personal'); + $this->assertFalse($cursor->canMoveBack()); + + // Middle position can move a previous step + $cursor = new FlowCursor($steps, 'professional'); + $this->assertTrue($cursor->canMoveBack()); + + // Last step can move a previous step + $cursor = new FlowCursor($steps, 'account'); + $this->assertTrue($cursor->canMoveBack()); + } + + public function testCanMoveNext() + { + $steps = self::createSteps(); + + // First position can move next step + $cursor = new FlowCursor($steps, 'personal'); + $this->assertTrue($cursor->canMoveNext()); + + // Middle position can move next step + $cursor = new FlowCursor($steps, 'professional'); + $this->assertTrue($cursor->canMoveNext()); + + // Last position cannot move the next step + $cursor = new FlowCursor($steps, 'account'); + $this->assertFalse($cursor->canMoveNext()); + } + + public function testCursorWithSingleStep() + { + $steps = ['single']; + $cursor = new FlowCursor(self::createSteps($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()); + } + + public function testNestedStepsAreFlattened() + { + $cursor = new FlowCursor(self::createNestedSteps(), 'intro'); + + $this->assertSame(['intro', 'personal', 'name', 'contact', 'summary'], $cursor->getSteps()); + $this->assertSame(5, $cursor->getTotalSteps()); + } + + public function testNavigationWithNestedSteps() + { + $nestedSteps = self::createNestedSteps(); + + // Forward: intro → personal → name → contact → summary + // Backward: summary → contact → name → personal → intro + + $cursor = new FlowCursor($nestedSteps, 'intro'); + $this->assertTrue($cursor->isFirstStep()); + $this->assertNull($cursor->getPreviousStep()); + $this->assertSame('personal', $cursor->getNextStep()); + + $cursor = new FlowCursor($nestedSteps, 'personal'); + $this->assertSame('intro', $cursor->getPreviousStep()); + $this->assertSame('name', $cursor->getNextStep()); + $this->assertSame(1, $cursor->getStepIndex()); + + $cursor = new FlowCursor($nestedSteps, 'name'); + $this->assertSame('personal', $cursor->getPreviousStep()); + $this->assertSame('contact', $cursor->getNextStep()); + $this->assertSame(2, $cursor->getStepIndex()); + + $cursor = new FlowCursor($nestedSteps, 'contact'); + $this->assertSame('name', $cursor->getPreviousStep()); + $this->assertSame('summary', $cursor->getNextStep()); + $this->assertSame(3, $cursor->getStepIndex()); + + $cursor = new FlowCursor($nestedSteps, 'summary'); + $this->assertTrue($cursor->isLastStep()); + $this->assertSame('contact', $cursor->getPreviousStep()); + $this->assertNull($cursor->getNextStep()); + $this->assertSame(4, $cursor->getStepIndex()); + } + + public function testNestedStepsWithMultipleForests() + { + $personal = (new FlowStepBuilder('personal')) + ->addStep('name') + ->addStep('email'); + $work = (new FlowStepBuilder('work')) + ->addStep('company') + ->addStep('role'); + + $cursor = new FlowCursor([ + 'intro' => (new FlowStepBuilder('intro'))->getStepConfig(), + 'personal' => $personal->getStepConfig(), + 'middle' => (new FlowStepBuilder('middle'))->getStepConfig(), + 'work' => $work->getStepConfig(), + 'summary' => (new FlowStepBuilder('summary'))->getStepConfig(), + ], 'intro'); + + $this->assertSame(['intro', 'personal', 'name', 'email', 'middle', 'work', 'company', 'role', 'summary'], $cursor->getSteps()); + $this->assertSame(9, $cursor->getTotalSteps()); + $this->assertSame('intro', $cursor->getFirstStep()); + $this->assertSame('summary', $cursor->getLastStep()); + } + + public function testInvalidStepInNestedStructure() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Step "invalid" does not exist. Available steps are: "intro", "personal", "name", "contact", "summary".'); + + new FlowCursor(self::createNestedSteps(), 'invalid'); + } + + public function testStringKeyNestedStepsWithDepth() + { + $position = (new FlowStepBuilder('position')) + ->addStep('title') + ->addStep('department'); + $work = (new FlowStepBuilder('work')) + ->addStep('company') + ->addStep($position); + $personal = (new FlowStepBuilder('personal')) + ->addStep('name') + ->addStep('contact'); + + $cursor = new FlowCursor([ + 'intro' => (new FlowStepBuilder('intro'))->getStepConfig(), + 'personal' => $personal->getStepConfig(), + 'work' => $work->getStepConfig(), + 'summary' => (new FlowStepBuilder('summary'))->getStepConfig(), + ], 'intro'); + + $this->assertSame([ + 'intro', + 'personal', 'name', 'contact', + 'work', 'company', 'position', 'title', 'department', + 'summary', + ], $cursor->getSteps()); + } + + public function testGetCurrentNode() + { + $cursor = new FlowCursor(self::createNestedSteps(), 'name'); + + $this->assertSame('name', $cursor->getCurrentStepNode()->getName()); + } + + public function testGetNode() + { + $cursor = new FlowCursor(self::createNestedSteps(), 'intro'); + + $this->assertSame('contact', $cursor->getStepNode('contact')->getName()); + } + + public function testGetNodeThrowsForInvalidName() + { + $cursor = new FlowCursor(self::createNestedSteps(), 'intro'); + + $this->expectException(InvalidArgumentException::class); + $cursor->getStepNode('invalid'); + } + + public function testGetRoots() + { + $cursor = new FlowCursor(self::createNestedSteps(), 'intro'); + + $this->assertCount(3, $cursor->getRootStepNodes()); + $this->assertSame('intro', $cursor->getRootStepNodes()[0]->getName()); + } + + public function testGetParentStep() + { + $steps = self::createNestedSteps(); + + $this->assertNull((new FlowCursor($steps, 'intro'))->getParentStep()); + $this->assertNull((new FlowCursor($steps, 'personal'))->getParentStep()); + $this->assertSame('personal', (new FlowCursor($steps, 'name'))->getParentStep()); + $this->assertSame('personal', (new FlowCursor($steps, 'contact'))->getParentStep()); + } + + public function testGetChildSteps() + { + $steps = self::createNestedSteps(); + + $this->assertSame([], (new FlowCursor($steps, 'intro'))->getChildSteps()); + $this->assertSame(['name', 'contact'], (new FlowCursor($steps, 'personal'))->getChildSteps()); + $this->assertSame([], (new FlowCursor($steps, 'name'))->getChildSteps()); + } + + public function testWithCurrentStepSharesForest() + { + $cursor = new FlowCursor(self::createNestedSteps(), 'intro'); + $newCursor = $cursor->withCurrentStep('summary'); + + $this->assertSame($cursor->getRootStepNodes(), $newCursor->getRootStepNodes()); + $this->assertSame($cursor->getStepNode('intro'), $newCursor->getStepNode('intro')); + } + + public function testIsFirstStepWithGroupParent() + { + $steps = self::createGroupSteps(); + + $this->assertTrue((new FlowCursor($steps, 'a1'))->isFirstStep()); + $this->assertFalse((new FlowCursor($steps, 'a2'))->isFirstStep()); + $this->assertFalse((new FlowCursor($steps, 'b'))->isFirstStep()); + $this->assertFalse((new FlowCursor($steps, 'c1'))->isFirstStep()); + } + + public function testIsLastStepWithGroupParent() + { + $steps = self::createGroupSteps(); + + $this->assertTrue((new FlowCursor($steps, 'c1'))->isLastStep()); + $this->assertFalse((new FlowCursor($steps, 'b'))->isLastStep()); + $this->assertFalse((new FlowCursor($steps, 'a2'))->isLastStep()); + $this->assertFalse((new FlowCursor($steps, 'a1'))->isLastStep()); + } + + public function testCanMoveBackWithGroupParent() + { + $steps = self::createGroupSteps(); + + $this->assertFalse((new FlowCursor($steps, 'a1'))->canMoveBack()); + $this->assertTrue((new FlowCursor($steps, 'a2'))->canMoveBack()); + $this->assertTrue((new FlowCursor($steps, 'b'))->canMoveBack()); + $this->assertTrue((new FlowCursor($steps, 'c1'))->canMoveBack()); + } + + public function testCanMoveNextWithGroupParent() + { + $steps = self::createGroupSteps(); + + $this->assertFalse((new FlowCursor($steps, 'c1'))->canMoveNext()); + $this->assertTrue((new FlowCursor($steps, 'b'))->canMoveNext()); + $this->assertTrue((new FlowCursor($steps, 'a2'))->canMoveNext()); + $this->assertTrue((new FlowCursor($steps, 'a1'))->canMoveNext()); + } + + public function testGetFirstStepWithGroupRoot() + { + $steps = self::createGroupSteps(); + + $this->assertSame('a1', (new FlowCursor($steps, 'b'))->getFirstStep()); + } + + public function testGetLastStepWithGroupTail() + { + $steps = self::createGroupSteps(); + + $this->assertSame('c1', (new FlowCursor($steps, 'b'))->getLastStep()); + } + + /** + * Creates steps: a(group) -> [a1, a2], b, c(group) -> [c1] + * + * @return array + */ + private static function createGroupSteps(): array + { + $a = (new FlowStepBuilder('a')) + ->setGroup(true) + ->addStep('a1') + ->addStep('a2'); + $c = (new FlowStepBuilder('c')) + ->setGroup(true) + ->addStep('c1'); + + return [ + 'a' => $a->getStepConfig(), + 'b' => (new FlowStepBuilder('b'))->getStepConfig(), + 'c' => $c->getStepConfig(), + ]; + } +} diff --git a/tests/Flow/FlowStepNodeTest.php b/tests/Flow/FlowStepNodeTest.php new file mode 100644 index 0000000..82788e0 --- /dev/null +++ b/tests/Flow/FlowStepNodeTest.php @@ -0,0 +1,366 @@ + + */ + private static function createNodes(array $steps): array + { + $configs = []; + foreach (self::buildBuilders($steps) as $name => $builder) { + $configs[$name] = $builder->getStepConfig(); + } + + return FlowStepNode::fromConfig($configs); + } + + /** + * @return array + */ + private static function buildBuilders(array $steps): array + { + $builders = []; + foreach ($steps as $key => $value) { + if (\is_int($key)) { + $builders[$value] = new FlowStepBuilder($value); + } else { + $builder = new FlowStepBuilder($key); + foreach (self::buildBuilders($value) as $child) { + $builder->addStep($child); + } + $builders[$key] = $builder; + } + } + + return $builders; + } + + public function testFlatSteps() + { + $roots = self::createNodes(['personal', 'professional', 'account']); + + $this->assertCount(3, $roots); + $this->assertSame('personal', $roots[0]->getName()); + $this->assertSame('professional', $roots[1]->getName()); + $this->assertSame('account', $roots[2]->getName()); + + foreach ($roots as $root) { + $this->assertNull($root->getParent()); + $this->assertEmpty($root->getChildren()); + } + } + + public function testNestedSteps() + { + $roots = self::createNodes([ + 'intro', + 'personal' => ['name', 'contact'], + 'summary', + ]); + + $this->assertCount(3, $roots); + + $this->assertSame('intro', $roots[0]->getName()); + $this->assertEmpty($roots[0]->getChildren()); + + $this->assertSame('personal', $roots[1]->getName()); + $this->assertCount(2, $roots[1]->getChildren()); + $this->assertSame('name', $roots[1]->getChildren()[0]->getName()); + $this->assertSame('contact', $roots[1]->getChildren()[1]->getName()); + + $this->assertSame($roots[1], $roots[1]->getChildren()[0]->getParent()); + $this->assertSame($roots[1], $roots[1]->getChildren()[1]->getParent()); + + $this->assertSame('summary', $roots[2]->getName()); + $this->assertEmpty($roots[2]->getChildren()); + } + + public function testDeepNesting() + { + $roots = self::createNodes([ + 'work' => [ + 'company', + 'position' => ['title', 'department'], + ], + ]); + + $this->assertCount(1, $roots); + $work = $roots[0]; + $this->assertSame('work', $work->getName()); + $this->assertCount(2, $work->getChildren()); + + $company = $work->getChildren()[0]; + $this->assertSame('company', $company->getName()); + $this->assertEmpty($company->getChildren()); + + $position = $work->getChildren()[1]; + $this->assertSame('position', $position->getName()); + $this->assertCount(2, $position->getChildren()); + $this->assertSame('title', $position->getChildren()[0]->getName()); + $this->assertSame('department', $position->getChildren()[1]->getName()); + + $this->assertSame($work, $company->getParent()); + $this->assertSame($work, $position->getParent()); + $this->assertSame($position, $position->getChildren()[0]->getParent()); + $this->assertSame($position, $position->getChildren()[1]->getParent()); + } + + public function testParentLinksAcrossMultipleLevels() + { + $roots = self::createNodes([ + 'root' => [ + 'l1' => [ + 'l2' => ['l3a', 'l3b'], + ], + ], + ]); + + $root = $roots[0]; + $l1 = $root->getChildren()[0]; + $l2 = $l1->getChildren()[0]; + $l3a = $l2->getChildren()[0]; + $l3b = $l2->getChildren()[1]; + + $this->assertNull($root->getParent()); + $this->assertSame($root, $l1->getParent()); + $this->assertSame($l1, $l2->getParent()); + $this->assertSame($l2, $l3a->getParent()); + $this->assertSame($l2, $l3b->getParent()); + + $this->assertSame('l2', $l3a->getParent()->getName()); + $this->assertSame('l1', $l3a->getParent()->getParent()->getName()); + $this->assertSame('root', $l3a->getParent()->getParent()->getParent()->getName()); + $this->assertNull($l3a->getParent()->getParent()->getParent()->getParent()); + } + + public function testSiblingLinks() + { + $roots = self::createNodes(['a', 'b', 'c']); + + $this->assertNull($roots[0]->getPreviousSibling()); + $this->assertSame($roots[1], $roots[0]->getNextSibling()); + + $this->assertSame($roots[0], $roots[1]->getPreviousSibling()); + $this->assertSame($roots[2], $roots[1]->getNextSibling()); + + $this->assertSame($roots[1], $roots[2]->getPreviousSibling()); + $this->assertNull($roots[2]->getNextSibling()); + } + + public function testSiblingLinksForChildren() + { + $roots = self::createNodes([ + 'personal' => ['name', 'email', 'phone'], + ]); + $children = $roots[0]->getChildren(); + + $this->assertNull($children[0]->getPreviousSibling()); + $this->assertSame($children[1], $children[0]->getNextSibling()); + + $this->assertSame($children[0], $children[1]->getPreviousSibling()); + $this->assertSame($children[2], $children[1]->getNextSibling()); + + $this->assertSame($children[1], $children[2]->getPreviousSibling()); + $this->assertNull($children[2]->getNextSibling()); + } + + public function testNextInTraversalDFSOrder() + { + $roots = self::createNodes([ + 'intro', + 'personal' => ['name', 'contact'], + 'summary', + ]); + + $names = []; + $node = $roots[0]; + while (null !== $node) { + $names[] = $node->getName(); + $node = $node->getNextInTraversal(); + } + + $this->assertSame(['intro', 'personal', 'name', 'contact', 'summary'], $names); + } + + public function testPreviousInTraversalOrder() + { + $roots = self::createNodes([ + 'intro', + 'personal' => ['name', 'contact'], + 'summary', + ]); + + $last = $roots[\count($roots) - 1]; + $this->assertSame('summary', $last->getName()); + + $names = []; + $node = $last; + while (null !== $node) { + $names[] = $node->getName(); + $node = $node->getPreviousInTraversal(); + } + + $this->assertSame(['summary', 'contact', 'name', 'personal', 'intro'], $names); + } + + public function testNextInTraversalWithDeepNesting() + { + $roots = self::createNodes([ + 'intro', + 'work' => [ + 'company', + 'position' => ['title', 'department'], + ], + 'summary', + ]); + + $names = []; + $node = $roots[0]; + while (null !== $node) { + $names[] = $node->getName(); + $node = $node->getNextInTraversal(); + } + + $this->assertSame(['intro', 'work', 'company', 'position', 'title', 'department', 'summary'], $names); + } + + public function testNextInTraversalFromLastNodeReturnsNull() + { + $roots = self::createNodes(['a', 'b']); + + $this->assertNull($roots[1]->getNextInTraversal()); + } + + public function testPreviousInTraversalFromFirstNodeReturnsNull() + { + $roots = self::createNodes(['a', 'b']); + + $this->assertNull($roots[0]->getPreviousInTraversal()); + } + + public function testIsSkippedWithNullSkip() + { + $roots = self::createNodes(['step']); + + $this->assertNull($roots[0]->getSkip()); + $this->assertFalse($roots[0]->isGroupOrSkipped('data')); + } + + public function testGroupNodeIsSkipped() + { + $stepA = (new FlowStepBuilder('stepA')) + ->setGroup(true) + ->addStep('stepA1') + ->addStep('stepA2'); + $roots = FlowStepNode::fromConfig(['stepA' => $stepA->getStepConfig()]); + + $this->assertTrue($roots[0]->isGroup()); + $this->assertTrue($roots[0]->isGroupOrSkipped(null)); + + $this->assertFalse($roots[0]->getChildren()[0]->isGroupOrSkipped(null)); + $this->assertFalse($roots[0]->getChildren()[1]->isGroupOrSkipped(null)); + } + + public function testSkipPropagatesToChildren() + { + $stepB = (new FlowStepBuilder('stepB')) + ->setSkip(fn () => true) + ->addStep('stepB1') + ->addStep('stepB2'); + $roots = FlowStepNode::fromConfig(['stepB' => $stepB->getStepConfig()]); + + $this->assertTrue($roots[0]->isGroupOrSkipped(null)); + $this->assertTrue($roots[0]->getChildren()[0]->isGroupOrSkipped(null)); + $this->assertTrue($roots[0]->getChildren()[1]->isGroupOrSkipped(null)); + } + + public function testSkipPropagatesAcrossMultipleLevels() + { + $stepA = (new FlowStepBuilder('stepA')) + ->setSkip(fn () => true) + ->addStep( + (new FlowStepBuilder('stepA1')) + ->addStep('stepA11') + ); + $roots = FlowStepNode::fromConfig(['stepA' => $stepA->getStepConfig()]); + + $this->assertTrue($roots[0]->isGroupOrSkipped(null)); + $stepA1 = $roots[0]->getChildren()[0]; + $this->assertTrue($stepA1->isGroupOrSkipped(null)); + $this->assertTrue($stepA1->getChildren()[0]->isGroupOrSkipped(null)); + } + + public function testSkipDoesNotPropagateWhenParentNotSkipped() + { + $stepA = (new FlowStepBuilder('stepA')) + ->addStep( + (new FlowStepBuilder('stepA1')) + ->setSkip(fn () => true) + ->addStep('stepA11') + ) + ->addStep('stepA2'); + $roots = FlowStepNode::fromConfig(['stepA' => $stepA->getStepConfig()]); + + $this->assertFalse($roots[0]->isGroupOrSkipped(null)); + + $stepA1 = $roots[0]->getChildren()[0]; + $this->assertTrue($stepA1->isGroupOrSkipped(null)); + $this->assertTrue($stepA1->getChildren()[0]->isGroupOrSkipped(null)); + + $this->assertFalse($roots[0]->getChildren()[1]->isGroupOrSkipped(null)); + } + + public function testGroupWithSkipOnChildrenAreSkipped() + { + $stepA = (new FlowStepBuilder('stepA')) + ->setGroup(true) + ->setSkip(fn () => true) + ->addStep('stepA1') + ->addStep('stepA2'); + + $roots = FlowStepNode::fromConfig(['stepA' => $stepA->getStepConfig()]); + + $this->assertTrue($roots[0]->isGroupOrSkipped(null)); + $this->assertTrue($roots[0]->getChildren()[0]->isGroupOrSkipped(null)); + $this->assertTrue($roots[0]->getChildren()[1]->isGroupOrSkipped(null)); + } + + public function testGroupWithNoChildrenThrows() + { + $stepA = (new FlowStepBuilder('stepA')) + ->setGroup(true); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Step "stepA" is marked as group but has no child steps.'); + + FlowStepNode::fromConfig(['stepA' => $stepA->getStepConfig()]); + } + + public function testMultipleForests() + { + $roots = self::createNodes([ + 'intro', + 'personal' => ['name', 'email'], + 'middle', + 'work' => ['company', 'role'], + 'summary', + ]); + + $names = []; + $node = $roots[0]; + while (null !== $node) { + $names[] = $node->getName(); + $node = $node->getNextInTraversal(); + } + + $this->assertSame(['intro', 'personal', 'name', 'email', 'middle', 'work', 'company', 'role', 'summary'], $names); + } +} diff --git a/tests/Flow/FormFlowTest.php b/tests/Flow/FormFlowTest.php new file mode 100644 index 0000000..26b6b00 --- /dev/null +++ b/tests/Flow/FormFlowTest.php @@ -0,0 +1,1443 @@ +setMetadataFactory(new LazyLoadingMetadataFactory(new AttributeLoader())) + ->getValidator(); + + $this->factory = Forms::createFormFactoryBuilder() + ->setResolvedTypeFactory(new ResolvedFormTypeFactory()) + ->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', + 'level' => 0, + 'index' => 0, + 'position' => 1, + 'is_before_current_step' => false, + 'is_current_step' => true, + 'has_current_step_descendant' => false, + 'is_after_current_step' => false, + 'can_be_skipped' => false, + 'is_skipped' => false, + 'is_group' => false, + 'children' => [], + 'visible_children' => [], + ]; + $step2 = [ + 'name' => 'professional', + 'level' => 0, + 'index' => 1, + 'position' => -1, + 'is_before_current_step' => false, + 'is_current_step' => false, + 'has_current_step_descendant' => false, + 'is_after_current_step' => true, + 'can_be_skipped' => true, + 'is_skipped' => true, + 'is_group' => false, + 'children' => [], + 'visible_children' => [], + ]; + $step3 = [ + 'name' => 'account', + 'level' => 0, + 'index' => 2, + 'position' => 2, + 'is_before_current_step' => false, + 'is_current_step' => false, + 'has_current_step_descendant' => false, + 'is_after_current_step' => true, + 'can_be_skipped' => false, + 'is_skipped' => false, + 'is_group' => false, + 'children' => [], + 'visible_children' => [], + ]; + + self::assertSame($step1, $view->vars['steps']['personal']); + self::assertSame($step2, $view->vars['steps']['professional']); + self::assertSame($step3, $view->vars['steps']['account']); + self::assertSame($step1, $view->vars['visible_steps']['personal']); + self::assertSame($step3, $view->vars['visible_steps']['account']); + } + + public function testWholeStepsFlow() + { + $data = new UserSignUp(); + $flow = $this->factory->create(UserSignUpType::class, $data); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertFalse($flow->isSubmitted()); + self::assertNull($flow->getClickedButton()); + self::assertTrue($flow->has('personal')); + self::assertTrue($flow->has('navigator')); + + $stepForm = $flow->get('personal'); + self::assertCount(3, $stepForm->all()); + self::assertTrue($stepForm->has('firstName')); + self::assertTrue($stepForm->has('lastName')); + self::assertTrue($stepForm->has('worker')); + + $navigatorForm = $flow->get('navigator'); + self::assertCount(2, $navigatorForm->all()); + self::assertTrue($navigatorForm->has('reset')); + self::assertTrue($navigatorForm->has('next')); + + $flow->submit([ + 'personal' => [ + 'firstName' => 'John', + 'lastName' => 'Doe', + 'worker' => '1', + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedButton()); + self::assertTrue($button->isNextAction()); + self::assertTrue($button->isClicked()); + + $flow = $flow->getStepForm(); + + self::assertSame('professional', $data->currentStep); + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertFalse($flow->isSubmitted()); + self::assertNull($flow->getClickedButton()); + self::assertTrue($flow->has('professional')); + self::assertTrue($flow->has('navigator')); + + $stepForm = $flow->get('professional'); + self::assertCount(2, $stepForm->all()); + self::assertTrue($stepForm->has('company')); + self::assertTrue($stepForm->has('role')); + + $navigatorForm = $flow->get('navigator'); + self::assertCount(4, $navigatorForm->all()); + self::assertTrue($navigatorForm->has('reset')); + self::assertTrue($navigatorForm->has('previous')); + self::assertTrue($navigatorForm->has('skip')); + self::assertTrue($navigatorForm->has('next')); + + $flow->submit([ + 'professional' => [ + 'company' => 'Acme', + 'role' => 'ROLE_DEVELOPER', + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedButton()); + self::assertTrue($button->isNextAction()); + self::assertTrue($button->isClicked()); + + $flow = $flow->getStepForm(); + + /** @var UserSignUp $data */ + $data = $flow->getViewData(); + self::assertSame('account', $data->currentStep); + self::assertSame('account', $flow->getCursor()->getCurrentStep()); + self::assertFalse($flow->isSubmitted()); + self::assertNull($flow->getClickedButton()); + self::assertTrue($flow->has('account')); + self::assertTrue($flow->has('navigator')); + + $stepForm = $flow->get('account'); + self::assertCount(2, $stepForm->all()); + self::assertTrue($stepForm->has('email')); + self::assertTrue($stepForm->has('password')); + + $navigatorForm = $flow->get('navigator'); + self::assertCount(3, $navigatorForm->all()); + self::assertTrue($navigatorForm->has('reset')); + self::assertTrue($navigatorForm->has('previous')); + self::assertTrue($navigatorForm->has('finish')); + + $flow->submit([ + 'account' => [ + 'email' => 'john@acme.com', + 'password' => 'eBvU2vBLfSXqf36', + ], + 'navigator' => [ + 'finish' => '', + ], + ]); + + self::assertSame('account', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertTrue($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedButton()); + self::assertTrue($button->isFinishAction()); + self::assertTrue($button->isClicked()); + + self::assertSame($data, $flow->getViewData()); + self::assertSame('John', $data->firstName); + self::assertSame('Doe', $data->lastName); + self::assertTrue($data->worker); + self::assertSame('Acme', $data->company); + self::assertSame('ROLE_DEVELOPER', $data->role); + self::assertSame('john@acme.com', $data->email); + self::assertSame('eBvU2vBLfSXqf36', $data->password); + } + + public function testPreviousActionWithPurgeSubmission() + { + $data = new UserSignUp(); + $data->firstName = 'John'; + $data->lastName = 'Doe'; + $data->worker = true; + $data->currentStep = 'professional'; + $flow = $this->factory->create(UserSignUpType::class, $data); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('professional')); + + $flow->submit([ + 'professional' => [ + 'company' => 'Acme', + 'role' => 'ROLE_DEVELOPER', + ], + 'navigator' => [ + 'previous' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedButton()); + self::assertTrue($button->isPreviousAction()); + + $flow = $flow->getStepForm(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal'), 'back action should move the flow one step back'); + self::assertNull($data->company, 'pro step should be silenced on submit'); + self::assertNull($data->role, 'pro step should be silenced on submit'); + } + + public function testPreviousActionWithoutPurgeSubmission() + { + $data = new UserSignUp(); + $data->firstName = 'John'; + $data->lastName = 'Doe'; + $data->worker = true; + $data->currentStep = 'professional'; + + $flow = $this->factory->create(UserSignUpType::class, $data); + // previous action without purge submission + $flow->get('navigator')->add('previous', FlowPreviousType::class, [ + 'validate' => false, + 'validation_groups' => false, + 'clear_submission' => false, + 'include_if' => static fn (FlowCursor $cursor) => $cursor->canMoveBack(), + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('professional')); + + $flow->submit([ + 'professional' => [ + 'company' => 'Acme', + 'role' => 'ROLE_DEVELOPER', + ], + 'navigator' => [ + 'previous' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedButton()); + self::assertTrue($button->isPreviousAction()); + + $flow = $flow->getStepForm(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal'), 'previous action should move the flow one step back'); + self::assertSame('Acme', $data->company, 'pro step should NOT be silenced on submit'); + self::assertSame('ROLE_DEVELOPER', $data->role, 'pro step should NOT be silenced on submit'); + } + + public function testSkipStepBasedOnData() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal')); + + $flow->submit([ + 'personal' => [ + 'firstName' => 'John', + 'lastName' => 'Doe', + // worker checkbox was not clicked + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedButton()); + self::assertTrue($button->isNextAction()); + + $flow = $flow->getStepForm(); + + self::assertFalse($flow->has('professional'), 'pro step should be skipped'); + self::assertSame('account', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('account')); + } + + public function testResetAction() + { + $data = new UserSignUp(); + $data->firstName = 'John'; + $data->lastName = 'Doe'; + $data->worker = true; + $data->currentStep = 'professional'; + + $dataStorage = new InMemoryDataStorage('user_sign_up'); + $dataStorage->save($data); + + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp(), [ + 'data_storage' => $dataStorage, + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('professional')); + + $flow->submit([ + 'professional' => [ + 'company' => 'Acme', + 'role' => 'ROLE_DEVELOPER', + ], + 'navigator' => [ + 'reset' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedButton()); + self::assertTrue($button->isResetAction()); + + $flow = $flow->getStepForm(); + /** @var UserSignUp $data */ + $data = $flow->getViewData(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal'), 'reset action should move the flow to the initial step'); + self::assertNull($data->firstName); + self::assertNull($data->lastName); + self::assertFalse($data->worker); + self::assertNull($data->company); + self::assertNull($data->role); + } + + public function testResetManually() + { + $data = new UserSignUp(); + $data->firstName = 'John'; + $data->lastName = 'Doe'; + $data->worker = true; + $data->currentStep = 'professional'; + + $dataStorage = new InMemoryDataStorage('user_sign_up'); + $dataStorage->save($data); + + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp(), [ + 'data_storage' => $dataStorage, + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + + $flow->reset(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + } + + public function testSkipAction() + { + $data = new UserSignUp(); + $data->firstName = 'John'; + $data->lastName = 'Doe'; + $data->worker = true; + $data->currentStep = 'professional'; + + $dataStorage = new InMemoryDataStorage('user_sign_up'); + $dataStorage->save($data); + + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp(), [ + 'data_storage' => $dataStorage, + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('professional')); + + $flow->submit([ + 'professional' => [ + 'company' => 'Acme', + 'role' => 'ROLE_DEVELOPER', + ], + 'navigator' => [ + 'skip' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedButton()); + self::assertTrue($button->isNextAction()); + self::assertSame('skip', $button->getName()); + + $flow = $flow->getStepForm(); + /** @var UserSignUp $data */ + $data = $flow->getViewData(); + + self::assertSame('account', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('account'), 'skip action should move the flow to the next step but skip submitted data and clear'); + self::assertSame('John', $data->firstName); + self::assertSame('Doe', $data->lastName); + self::assertTrue($data->worker); + self::assertNull($data->company); + self::assertNull($data->role); + } + + public function testTypeExtensionAndStepsPriority() + { + $factory = Forms::createFormFactoryBuilder() + ->setResolvedTypeFactory(new ResolvedFormTypeFactory()) + ->addTypeExtension(new UserSignUpTypeExtension()) + ->getFormFactory(); + + $flow = $factory->create(UserSignUpType::class, new UserSignUp()); + + self::assertSame('first', $flow->getCursor()->getCurrentStep()); + self::assertSame(['first', 'personal', 'professional', 'account', 'last'], $flow->getCursor()->getSteps()); + } + + public function testMoveBackToStep() + { + $data = new UserSignUp(); + $data->firstName = 'John'; + $data->lastName = 'Doe'; + $data->worker = true; + $data->company = 'Acme'; + $data->role = 'ROLE_DEVELOPER'; + $data->currentStep = 'account'; + + $flow = $this->factory->create(UserSignUpType::class, $data); + $flow->get('navigator')->add('back_to_step', FlowPreviousType::class, [ + 'validate' => false, + 'validation_groups' => false, + 'clear_submission' => false, + ]); + + self::assertSame('account', $flow->getCursor()->getCurrentStep()); + + $flow->submit([ + 'account' => [ + 'email' => 'jdoe@acme.com', + 'password' => '$ecret', + ], + 'navigator' => [ + 'back_to_step' => 'personal', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedButton()); + self::assertTrue($button->isPreviousAction()); + self::assertSame('personal', $button->getViewData()); + + $flow = $flow->getStepForm(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal')); + self::assertSame('John', $data->firstName); + self::assertSame('Acme', $data->company); + self::assertSame('jdoe@acme.com', $data->email); + } + + public function testMoveManually() + { + $data = new UserSignUp(); + $data->firstName = 'John'; + $data->lastName = 'Doe'; + $data->worker = true; + $data->currentStep = 'professional'; + + $dataStorage = new InMemoryDataStorage('user_sign_up'); + $dataStorage->save($data); + + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp(), [ + 'data_storage' => $dataStorage, + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('professional')); + + $flow->movePrevious(); + $flow = $flow->newStepForm(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal')); + + $flow->moveNext(); + $flow = $flow->newStepForm(); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('professional')); + } + + public function testInvalidMovePreviousUntilAheadStep() + { + $data = new UserSignUp(); + $data->currentStep = 'personal'; + $flow = $this->factory->create(UserSignUpType::class, $data); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot move back to step "account" because it is ahead of the current step "personal".'); + + $flow->movePrevious('account'); + } + + public function testInvalidMovePreviousUntilSkippedStep() + { + $data = new UserSignUp(); + $data->worker = false; + $data->currentStep = 'account'; + $flow = $this->factory->create(UserSignUpType::class, $data); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot move back to step "professional" because it is a skipped step.'); + + $flow->movePrevious('professional'); + } + + public function testInvalidStepForm() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal')); + + $flow->submit([ + 'personal' => [ + 'firstName' => '', // This value should not be blank + 'lastName' => 'Doe', + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertFalse($flow->isValid()); + self::assertNotNull($button = $flow->getClickedButton()); + self::assertTrue($button->isNextAction()); + self::assertSame($flow, $flow->getStepForm()); + self::assertSame('This value should not be blank.', $flow->getErrors(true)->current()->getMessage()); + } + + public function testCannotModifyStepConfigAfterFormBuilding() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('FlowStepBuilder methods cannot be accessed anymore once the builder is turned into a FlowStepConfigInterface instance.'); + + $flow->getConfig()->getStep('personal')->setPriority(0); + } + + public function testIgnoreSubmissionIfStepIsMissing() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('personal')); + + $flow->submit([ + 'account' => [ + 'firstName' => '', + 'lastName' => '', + ], + 'navigator' => [ + 'previous' => '', + ], + ]); + + self::assertFalse($flow->isSubmitted()); + } + + public function testViewVars() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + $view = $flow->createView(); + + self::assertInstanceOf(FlowCursor::class, $view->vars['cursor']); + self::assertCount(3, $view->vars['steps']); + self::assertSame(['personal', 'professional', 'account'], array_keys($view->vars['steps'])); + self::assertSame('personal', $view->vars['steps']['personal']['name']); + self::assertTrue($view->vars['steps']['personal']['is_current_step']); + self::assertFalse($view->vars['steps']['personal']['is_skipped']); + self::assertSame('professional', $view->vars['steps']['professional']['name']); + self::assertFalse($view->vars['steps']['professional']['is_current_step']); + self::assertTrue($view->vars['steps']['professional']['is_skipped']); + self::assertSame('account', $view->vars['steps']['account']['name']); + self::assertFalse($view->vars['steps']['account']['is_current_step']); + self::assertFalse($view->vars['steps']['account']['is_skipped']); + } + + public function testFallbackCurrentStep() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + + /** @var UserSignUp $data */ + $data = $flow->getViewData(); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep(), 'The current step should be the first one depending on the step priority'); + self::assertSame('personal', $data->currentStep); + } + + public function testInitialCurrentStep() + { + $data = new UserSignUp(); + $data->currentStep = 'professional'; + $flow = $this->factory->create(UserSignUpType::class, $data); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep(), 'The current step should be the one set in the initial data'); + self::assertSame('professional', $data->currentStep); + } + + public function testFormFlowWithArrayData() + { + $flow = $this->factory->create(UserSignUpType::class, [], [ + 'data_class' => null, + 'step_property_path' => '[currentStep]', + ]); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertFalse($flow->isSubmitted()); + self::assertNull($flow->getClickedButton()); + self::assertTrue($flow->has('personal')); + self::assertTrue($flow->has('navigator')); + + $stepForm = $flow->get('personal'); + self::assertCount(3, $stepForm->all()); + self::assertTrue($stepForm->has('firstName')); + self::assertTrue($stepForm->has('lastName')); + self::assertTrue($stepForm->has('worker')); + + $navigatorForm = $flow->get('navigator'); + self::assertCount(2, $navigatorForm->all()); + self::assertTrue($navigatorForm->has('reset')); + self::assertTrue($navigatorForm->has('next')); + + $flow->submit([ + 'personal' => [ + 'firstName' => 'John', + 'lastName' => 'Doe', + 'worker' => '1', + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedButton()); + self::assertTrue($button->isNextAction()); + self::assertTrue($button->isClicked()); + + $flow = $flow->getStepForm(); + + $data = $flow->getData(); + self::assertSame('professional', $data['currentStep']); + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertFalse($flow->isSubmitted()); + self::assertNull($flow->getClickedButton()); + self::assertTrue($flow->has('professional')); + self::assertTrue($flow->has('navigator')); + + $stepForm = $flow->get('professional'); + self::assertCount(2, $stepForm->all()); + self::assertTrue($stepForm->has('company')); + self::assertTrue($stepForm->has('role')); + + $navigatorForm = $flow->get('navigator'); + self::assertCount(4, $navigatorForm->all()); + self::assertTrue($navigatorForm->has('reset')); + self::assertTrue($navigatorForm->has('previous')); + self::assertTrue($navigatorForm->has('skip')); + self::assertTrue($navigatorForm->has('next')); + + $flow->submit([ + 'professional' => [ + 'company' => 'Acme', + 'role' => 'ROLE_DEVELOPER', + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertFalse($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedButton()); + self::assertTrue($button->isNextAction()); + self::assertTrue($button->isClicked()); + + $flow = $flow->getStepForm(); + + $data = $flow->getData(); + self::assertSame('account', $data['currentStep']); + self::assertSame('account', $flow->getCursor()->getCurrentStep()); + self::assertFalse($flow->isSubmitted()); + self::assertNull($flow->getClickedButton()); + self::assertTrue($flow->has('account')); + self::assertTrue($flow->has('navigator')); + + $stepForm = $flow->get('account'); + self::assertCount(2, $stepForm->all()); + self::assertTrue($stepForm->has('email')); + self::assertTrue($stepForm->has('password')); + + $navigatorForm = $flow->get('navigator'); + self::assertCount(3, $navigatorForm->all()); + self::assertTrue($navigatorForm->has('reset')); + self::assertTrue($navigatorForm->has('previous')); + self::assertTrue($navigatorForm->has('finish')); + + $flow->submit([ + 'account' => [ + 'email' => 'john@acme.com', + 'password' => 'eBvU2vBLfSXqf36', + ], + 'navigator' => [ + 'finish' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertTrue($flow->isFinished()); + self::assertNotNull($button = $flow->getClickedButton()); + self::assertTrue($button->isFinishAction()); + self::assertTrue($button->isClicked()); + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + + $data = $flow->getData(); + self::assertSame('John', $data['firstName']); + self::assertSame('Doe', $data['lastName']); + self::assertTrue($data['worker']); + self::assertSame('Acme', $data['company']); + self::assertSame('ROLE_DEVELOPER', $data['role']); + self::assertSame('john@acme.com', $data['email']); + self::assertSame('eBvU2vBLfSXqf36', $data['password']); + } + + public function testHandleActionManually() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + + $flow->submit([ + 'personal' => [ + 'firstName' => 'John', + 'lastName' => 'Doe', + 'worker' => '1', + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertNotNull($actionButton = $flow->getClickedButton()); + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + + $actionButton->handle(); + + self::assertSame('professional', $flow->getCursor()->getCurrentStep()); + } + + public function testAddFormErrorOnActionHandling() + { + $flow = $this->factory->create(UserSignUpType::class, new UserSignUp()); + $flow->get('navigator')->add('next', FlowNextType::class, [ + 'handler' => static function (mixed $data, FlowButtonInterface $button, FormFlowInterface $flow) { + $flow->addError(new FormError('Action error')); + }, + ]); + + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + + $flow->submit([ + 'personal' => [ + 'firstName' => 'John', + 'lastName' => 'Doe', + ], + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertNotNull($actionButton = $flow->getClickedButton()); + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + + $actionButton->handle(); + $flow = $flow->getStepForm(); + $errors = $flow->getErrors(true); + + self::assertFalse($flow->isValid()); + self::assertCount(1, $errors); + self::assertSame('Action error', $errors->current()->getMessage()); + self::assertSame('personal', $flow->getCursor()->getCurrentStep()); + } + + public function testStepValidationGroups() + { + $data = new UserSignUp(); + $data->worker = true; + $flow = $this->factory->create(UserSignUpType::class, $data); + + // Check that validation groups include the current step name + self::assertSame(['Default', 'personal'], $flow->getConfig()->getOption('validation_groups')($flow)); + + // Move to next step + $flow->moveNext(); + $flow = $flow->newStepForm(); + + // Check that validation groups are updated + self::assertEquals(['Default', 'professional'], $flow->getConfig()->getOption('validation_groups')($flow)); + } + + public function testLastStepSkippedMarkFlowAsFinished() + { + $flow = $this->factory->create(LastStepSkippedType::class, ['currentStep' => 'step1']); + + self::assertSame('step1', $flow->getCursor()->getCurrentStep()); + self::assertFalse($flow->isSubmitted()); + self::assertNull($flow->getClickedButton()); + self::assertTrue($flow->has('step1')); + self::assertTrue($flow->has('navigator')); + + $navigatorForm = $flow->get('navigator'); + self::assertCount(2, $navigatorForm->all()); + self::assertTrue($navigatorForm->has('next')); + self::assertTrue($navigatorForm->has('reset')); + + $flow->submit([ + 'step1' => 'foo', + 'navigator' => [ + 'next' => '', + ], + ]); + + self::assertSame('step1', $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()); + + $button->handle(); // $flow->moveNext() is called internally + + self::assertTrue($flow->isFinished()); + self::assertNotSame($flow, $flow->getStepForm()); + self::assertSame(['currentStep' => 'step1', 'step1' => 'foo'], $flow->getData()); + } + + public function testNestedStepsFlowConfig() + { + $flow = $this->factory->create(NestedStepsFlowType::class, []); + $config = $flow->getConfig(); + + self::assertTrue($config->hasStep('stepA')); + self::assertTrue($config->hasStep('stepA1')); + self::assertTrue($config->hasStep('stepA2')); + self::assertTrue($config->hasStep('stepA3')); + self::assertTrue($config->hasStep('stepB')); + self::assertTrue($config->hasStep('stepB1')); + self::assertTrue($config->hasStep('stepB11')); + self::assertTrue($config->hasStep('stepB12')); + self::assertTrue($config->hasStep('stepB2')); + self::assertTrue($config->hasStep('stepC')); + } + + public function testNestedStepsFlowNavigation() + { + $flow = $this->factory->create(NestedStepsFlowType::class, []); + + // stepA is group, so the initial step is its first child + self::assertSame('stepA1', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('stepA1')); + + $flow->submit([ + 'stepA1' => 'value1', + 'navigator' => ['next' => ''], + ]); + + $flow = $flow->getStepForm(); + + self::assertSame('stepA2', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('stepA2')); + + $flow->submit([ + 'stepA2' => 'value2', + 'navigator' => ['next' => ''], + ]); + + $flow = $flow->getStepForm(); + + self::assertSame('stepA3', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('stepA3')); + + $flow->submit([ + 'stepA3' => 'value3', + 'navigator' => ['next' => ''], + ]); + + $flow = $flow->getStepForm(); + + self::assertSame('stepB', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('stepB')); + + // Submit stepB with value 2 (SkipB2) - this means stepB1 is NOT skipped + $flow->submit([ + 'stepB' => '2', + 'navigator' => ['next' => ''], + ]); + + $flow = $flow->getStepForm(); + + // stepB1 is NOT skipped (skip condition returns false), it's a FormType container + self::assertSame('stepB1', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('stepB1')); + + $flow->submit([ + 'stepB1' => [], + 'navigator' => ['next' => ''], + ]); + + $flow = $flow->getStepForm(); + + self::assertSame('stepB11', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('stepB11')); + + $flow->submit([ + 'stepB11' => 'valueB11', + 'navigator' => ['next' => ''], + ]); + + $flow = $flow->getStepForm(); + + self::assertSame('stepB12', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('stepB12')); + + $flow->submit([ + 'stepB12' => 'valueB12', + 'navigator' => ['next' => ''], + ]); + + $flow = $flow->getStepForm(); + + self::assertSame('stepB2', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('stepB2')); + + $flow->submit([ + 'stepB2' => 'valueB2', + 'navigator' => ['next' => ''], + ]); + + $flow = $flow->getStepForm(); + + self::assertSame('stepC', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('stepC')); + self::assertTrue($flow->getCursor()->isLastStep()); + + // Finish the flow + $flow->submit([ + 'stepC' => 'valueC', + 'navigator' => ['finish' => ''], + ]); + + self::assertTrue($flow->isSubmitted()); + self::assertTrue($flow->isValid()); + self::assertTrue($flow->isFinished()); + + // Verify all data was collected + $data = $flow->getData(); + self::assertSame('value1', $data['stepA1']); + self::assertSame('value2', $data['stepA2']); + self::assertSame('value3', $data['stepA3']); + self::assertSame(2, $data['stepB']); // ChoiceType converts to int + self::assertSame('valueB11', $data['stepB11']); + self::assertSame('valueB12', $data['stepB12']); + self::assertSame('valueB2', $data['stepB2']); + self::assertSame('valueC', $data['stepC']); + } + + public function testNestedStepsFlowNavigationWithSkippedParent() + { + $flow = $this->factory->create(NestedStepsFlowType::class, []); + + // Navigate quickly to stepB + $flow->submit(['stepA' => [], 'navigator' => ['next' => '']]); + $flow = $flow->getStepForm(); + $flow->submit(['stepA1' => 'v1', 'navigator' => ['next' => '']]); + $flow = $flow->getStepForm(); + $flow->submit(['stepA2' => 'v2', 'navigator' => ['next' => '']]); + $flow = $flow->getStepForm(); + $flow->submit(['stepA3' => 'v3', 'navigator' => ['next' => '']]); + $flow = $flow->getStepForm(); + + self::assertSame('stepB', $flow->getCursor()->getCurrentStep()); + + // Submit stepB with value 1 (SkipB1) - stepB1 parent is skipped but children are not + $flow->submit([ + 'stepB' => '1', + 'navigator' => ['next' => ''], + ]); + + $flow = $flow->getStepForm(); + + // stepB1 is skipped via skip func, so its children (stepB11, stepB12) are also skipped + self::assertSame('stepB2', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('stepB2')); + + $flow->submit([ + 'stepB2' => 'valueB2', + 'navigator' => ['next' => ''], + ]); + + $flow = $flow->getStepForm(); + + self::assertSame('stepC', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->getCursor()->isLastStep()); + } + + public function testNestedStepsViewVars() + { + $flow = $this->factory->create(NestedStepsFlowType::class, []); + $view = $flow->createView(); + + // Top-level steps (3 in this case) + self::assertArrayHasKey('steps', $view->vars); + self::assertCount(3, $view->vars['steps']); + self::assertArrayHasKey('stepA', $view->vars['steps']); + self::assertArrayHasKey('stepB', $view->vars['steps']); + self::assertArrayHasKey('stepC', $view->vars['steps']); + + // Top-level index and position are level-specific + // stepA uses FormType (container) so it's skipped (position=-1) + self::assertSame(0, $view->vars['steps']['stepA']['index']); + self::assertSame(1, $view->vars['steps']['stepA']['position']); + self::assertFalse($view->vars['steps']['stepA']['is_skipped']); + // stepB uses ChoiceType, stepC uses TextType - both visible + self::assertSame(1, $view->vars['steps']['stepB']['index']); + self::assertSame(2, $view->vars['steps']['stepB']['position']); + self::assertFalse($view->vars['steps']['stepB']['is_skipped']); + self::assertSame(2, $view->vars['steps']['stepC']['index']); + self::assertSame(3, $view->vars['steps']['stepC']['position']); + self::assertFalse($view->vars['steps']['stepC']['is_skipped']); + + // has_current_step_descendant is true for current step (stepA1 is the initial step) + self::assertTrue($view->vars['steps']['stepA']['has_current_step_descendant']); + self::assertFalse($view->vars['steps']['stepB']['has_current_step_descendant']); + self::assertFalse($view->vars['steps']['stepC']['has_current_step_descendant']); + + // stepA children (stepA1, stepA2, stepA3) have level-specific index/position + $stepAChildren = $view->vars['steps']['stepA']['children']; + self::assertCount(3, $stepAChildren); + self::assertSame(0, $stepAChildren['stepA1']['index']); + self::assertSame(1, $stepAChildren['stepA1']['position']); + self::assertSame(1, $stepAChildren['stepA2']['index']); + self::assertSame(2, $stepAChildren['stepA2']['position']); + self::assertSame(2, $stepAChildren['stepA3']['index']); + self::assertSame(3, $stepAChildren['stepA3']['position']); + + // stepB children (stepB1, stepB2) + $stepBChildren = $view->vars['steps']['stepB']['children']; + self::assertCount(2, $stepBChildren); + // stepB1 has explicit setSkip that checks $data['stepB'] === 1, which is false for empty data + self::assertSame(0, $stepBChildren['stepB1']['index']); + self::assertSame(1, $stepBChildren['stepB1']['position']); + self::assertFalse($stepBChildren['stepB1']['is_skipped']); + // stepB2 is a TextType, so it's visible + self::assertSame(1, $stepBChildren['stepB2']['index']); + self::assertSame(2, $stepBChildren['stepB2']['position']); + self::assertFalse($stepBChildren['stepB2']['is_skipped']); + + // stepB1 nested children (stepB11, stepB12) + $stepB1Children = $stepBChildren['stepB1']['children']; + self::assertCount(2, $stepB1Children); + self::assertSame(0, $stepB1Children['stepB11']['index']); + self::assertSame(1, $stepB1Children['stepB11']['position']); + self::assertSame(1, $stepB1Children['stepB12']['index']); + self::assertSame(2, $stepB1Children['stepB12']['position']); + } + + public function testNestedStepsViewVarsWithDeeplyNestedCurrentStep() + { + // Set current step to a deeply nested child (stepB11) + $flow = $this->factory->create(NestedStepsFlowType::class, ['currentStep' => 'stepB11']); + $view = $flow->createView(); + + // All ancestors should have has_current_step_descendant=true + self::assertFalse($view->vars['steps']['stepA']['has_current_step_descendant']); + self::assertTrue($view->vars['steps']['stepB']['has_current_step_descendant']); + self::assertFalse($view->vars['steps']['stepC']['has_current_step_descendant']); + + // stepB1 (parent of stepB11) should have has_current_step_descendant=true + $stepBChildren = $view->vars['steps']['stepB']['children']; + self::assertTrue($stepBChildren['stepB1']['has_current_step_descendant']); + self::assertFalse($stepBChildren['stepB2']['has_current_step_descendant']); + + // stepB11 should have is_current_step=true + $stepB1Children = $stepBChildren['stepB1']['children']; + self::assertTrue($stepB1Children['stepB11']['is_current_step']); + self::assertFalse($stepB1Children['stepB12']['is_current_step']); + } + + public function testGroupViewVars() + { + $flow = $this->factory->create(GroupingStepsFlowType::class, []); + $view = $flow->createView(); + + // Current step is 'a1' (first visitable child of group 'a') + self::assertSame('a1', $flow->getCursor()->getCurrentStep()); + + // Top-level: a(group), b, c(group) + self::assertCount(3, $view->vars['steps']); + self::assertArrayHasKey('a', $view->vars['steps']); + self::assertArrayHasKey('b', $view->vars['steps']); + self::assertArrayHasKey('c', $view->vars['steps']); + + $a1 = [ + 'name' => 'a1', + 'level' => 1, + 'index' => 0, + 'position' => 1, + 'is_before_current_step' => false, + 'is_current_step' => true, + 'has_current_step_descendant' => false, + 'is_after_current_step' => false, + 'can_be_skipped' => false, + 'is_skipped' => false, + 'is_group' => false, + 'children' => [], + 'visible_children' => [], + ]; + $a2 = [ + 'name' => 'a2', + 'level' => 1, + 'index' => 1, + 'position' => 2, + 'is_before_current_step' => false, + 'is_current_step' => false, + 'has_current_step_descendant' => false, + 'is_after_current_step' => true, + 'can_be_skipped' => false, + 'is_skipped' => false, + 'is_group' => false, + 'children' => [], + 'visible_children' => [], + ]; + $c1 = [ + 'name' => 'c1', + 'level' => 1, + 'index' => 0, + 'position' => 1, + 'is_before_current_step' => false, + 'is_current_step' => false, + 'has_current_step_descendant' => false, + 'is_after_current_step' => true, + 'can_be_skipped' => false, + 'is_skipped' => false, + 'is_group' => false, + 'children' => [], + 'visible_children' => [], + ]; + + // Group 'a': level 0, before current step, has_current_step_descendant (a1 is current) + self::assertSame([ + 'name' => 'a', + 'level' => 0, + 'index' => 0, + 'position' => 1, + 'is_before_current_step' => true, + 'is_current_step' => false, + 'has_current_step_descendant' => true, + 'is_after_current_step' => false, + 'can_be_skipped' => false, + 'is_skipped' => false, + 'is_group' => true, + 'children' => ['a1' => $a1, 'a2' => $a2], + 'visible_children' => ['a1' => $a1, 'a2' => $a2], + ], $view->vars['steps']['a']); + + // Step 'b': level 0, after current step + self::assertSame([ + 'name' => 'b', + 'level' => 0, + 'index' => 1, + 'position' => 2, + 'is_before_current_step' => false, + 'is_current_step' => false, + 'has_current_step_descendant' => false, + 'is_after_current_step' => true, + 'can_be_skipped' => false, + 'is_skipped' => false, + 'is_group' => false, + 'children' => [], + 'visible_children' => [], + ], $view->vars['steps']['b']); + + // Group 'c': level 0, after current step + self::assertSame([ + 'name' => 'c', + 'level' => 0, + 'index' => 2, + 'position' => 3, + 'is_before_current_step' => false, + 'is_current_step' => false, + 'has_current_step_descendant' => false, + 'is_after_current_step' => true, + 'can_be_skipped' => false, + 'is_skipped' => false, + 'is_group' => true, + 'children' => ['c1' => $c1], + 'visible_children' => ['c1' => $c1], + ], $view->vars['steps']['c']); + } + + public function testGroupFirstRootResolvesToFirstChild() + { + $flow = $this->factory->create(GroupingStepsFlowType::class, []); + + // 'a' is group, initial step is 'a1' (first child) + self::assertSame('a1', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('a1')); + } + + public function testGroupForwardNavigationSkipsToChildren() + { + $flow = $this->factory->create(GroupingStepsFlowType::class, []); + + // Start at a1 (skipping group 'a') + self::assertSame('a1', $flow->getCursor()->getCurrentStep()); + + $flow->submit(['a1' => 'v1', 'navigator' => ['next' => '']]); + $flow = $flow->getStepForm(); + self::assertSame('a2', $flow->getCursor()->getCurrentStep()); + + $flow->submit(['a2' => 'v2', 'navigator' => ['next' => '']]); + $flow = $flow->getStepForm(); + + self::assertSame('b', $flow->getCursor()->getCurrentStep()); + } + + public function testGroupForwardFromLastNonGroupStepToGroupChild() + { + $flow = $this->factory->create(GroupingStepsFlowType::class, []); + + // Navigate to 'b' + $flow->submit(['a1' => 'v1', 'navigator' => ['next' => '']]); + $flow = $flow->getStepForm(); + $flow->submit(['a2' => 'v2', 'navigator' => ['next' => '']]); + $flow = $flow->getStepForm(); + self::assertSame('b', $flow->getCursor()->getCurrentStep()); + + // Move forward from 'b': next is 'c' (group/skipped) → 'c1' (first child) + $flow->submit(['b' => 'v3', 'navigator' => ['next' => '']]); + $flow = $flow->getStepForm(); + self::assertSame('c1', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('c1')); + } + + public function testGroupBackwardNavigationSkipsParent() + { + $flow = $this->factory->create(GroupingStepsFlowType::class, []); + + // Navigate forward to a2 + $flow->submit(['a1' => 'v1', 'navigator' => ['next' => '']]); + $flow = $flow->getStepForm(); + self::assertSame('a2', $flow->getCursor()->getCurrentStep()); + + // Navigate forward to b + $flow->submit(['a2' => 'v2', 'navigator' => ['next' => '']]); + $flow = $flow->getStepForm(); + self::assertSame('b', $flow->getCursor()->getCurrentStep()); + + // Move backward from 'b': previous in DFS is 'a2' (deepest last descendant of 'a') + // 'a' is group/skipped, so move() lands on 'a2' + $flow->submit(['b' => 'v3', 'navigator' => ['previous' => '']]); + $flow = $flow->getStepForm(); + self::assertSame('a2', $flow->getCursor()->getCurrentStep()); + } + + public function testGroupBackwardFromFirstChildOfGroupRootThrows() + { + $flow = $this->factory->create(GroupingStepsFlowType::class, []); + + self::assertSame('a1', $flow->getCursor()->getCurrentStep()); + + // Structurally there is a previous step ('a'), but it's group + // and there's nothing before it, so moving backward fails + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot determine previous step.'); + + $flow->movePrevious(); + } + + public function testFirstStepSkippedResolvesToSecondStep() + { + $flow = $this->factory->create(FirstStepSkippedType::class, []); + + // step1 is skipped, initial step resolves to step2 + self::assertSame('step2', $flow->getCursor()->getCurrentStep()); + self::assertTrue($flow->has('step2')); + } +} diff --git a/tests/Integration/FormFlowBasicTest.php b/tests/Integration/FormFlowBasicTest.php index 6ed6154..2a8855c 100644 --- a/tests/Integration/FormFlowBasicTest.php +++ b/tests/Integration/FormFlowBasicTest.php @@ -222,6 +222,10 @@ public function testResolvedFormTypeFactoryWithProfiler(): void private static function assertSameFileContent(string $expectedFilename, string $actualContent, bool $save = false): void { + // strip Symfony Form 7+ accessibility attributes so fixtures stay version-agnostic + $actualContent = preg_replace('/ id="[^"]*_error\d+"/', '', $actualContent); + $actualContent = preg_replace('/ aria-(describedby|invalid)="[^"]*"/', '', $actualContent); + $expectedContent = self::getOutputFileContent($expectedFilename, $actualContent, $save); self::assertSame($expectedContent, $actualContent);