Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
170 changes: 149 additions & 21 deletions src/Form/Flow/FlowCursor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<FlowStepNode> */
private array $roots;
/** @var array<string, FlowStepNode> */
private array $stepMap;
/** @var list<string> */
private array $steps;
private FlowStepNode $currentStep;

/**
* @param array<string> $steps
* @param array<string, FlowStepConfigInterface>|list<string> $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<string>
*/
public function getSteps(): array
{
return $this->steps;
Expand All @@ -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<string>
*/
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<FlowStepNode>
*/
public function getRootStepNodes(): array
{
return $this->roots;
}
}
92 changes: 91 additions & 1 deletion src/Form/Flow/FlowStepBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,25 @@
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
{
private bool $locked = false;
private int $priority = 0;
private ?\Closure $skip = null;
/** @var array<string, FlowStepBuilderInterface> */
private array $children = [];
private bool $group = false;

/**
* @param class-string<FormTypeInterface> $type
*/
public function __construct(
private readonly string $name,
private readonly string $type,
private readonly string $type = FormType::class,
private readonly array $options = [],
) {
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
}
Loading
Loading