Files
archived-form/Flow/FormFlow.php
2025-10-17 10:25:00 +02:00

244 lines
6.8 KiB
PHP

<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Form\Flow;
use Symfony\Component\Form\ClickableInterface;
use Symfony\Component\Form\Exception\AlreadySubmittedException;
use Symfony\Component\Form\Exception\InvalidArgumentException;
use Symfony\Component\Form\Exception\RuntimeException;
use Symfony\Component\Form\Exception\TransformationFailedException;
use Symfony\Component\Form\Form;
use Symfony\Component\Form\FormInterface;
/**
* FormFlow represents a multistep form.
*
* @author Yonel Ceruto <open@yceruto.dev>
*
* @implements \IteratorAggregate<string, FormInterface>
*/
class FormFlow extends Form implements FormFlowInterface
{
private ?ButtonFlowInterface $clickedFlowButton = null;
private bool $finished = false;
public function __construct(
private readonly FormFlowConfigInterface $config,
private FormFlowCursor $cursor,
) {
parent::__construct($config);
}
public function submit(mixed $submittedData, bool $clearMissing = true): static
{
if ($this->isSubmitted()) {
throw new AlreadySubmittedException('A form can only be submitted once.');
}
if (!\is_array($submittedData)) {
throw new TransformationFailedException('The submitted data must be an array.');
}
if (!$this->isCurrentStepSubmitted($submittedData)) {
// the submitted data doesn't match the current step,
// it's probably a reload of a POST visit from a different step
return $this;
}
$this->setClickedFlowButton($submittedData, $this);
parent::submit($submittedData, $clearMissing);
if (!$this->clickedFlowButton || !$this->isSubmitted() || !$this->isValid()) {
return $this;
}
$this->finished = $this->clickedFlowButton->isFinishAction();
if ($this->finished && $this->config->isAutoReset()) {
$this->reset();
}
return $this;
}
public function reset(): void
{
$this->config->getDataStorage()->clear();
$this->cursor = $this->cursor->withCurrentStep($this->config->getInitialStep());
}
public function movePrevious(?string $step = null): void
{
if ($step) {
$this->moveBackTo($step);
return;
}
if (!$this->move(fn (FormFlowCursor $cursor) => $cursor->getPreviousStep())) {
throw new RuntimeException('Cannot determine previous step.');
}
}
public function moveNext(): void
{
if (!$this->move(fn (FormFlowCursor $cursor) => $cursor->getNextStep())) {
throw new RuntimeException('Cannot determine next step.');
}
}
public function newStepForm(): static
{
return $this->config->getFormFactory()->createNamed($this->config->getName(), $this->config->getType()->getInnerType()::class, $this->getData(), $this->config->getInitialOptions());
}
public function getStepForm(): static
{
if (!$this->isSubmitted() || !$this->isValid()) {
return $this;
}
if ($this->clickedFlowButton && !$this->clickedFlowButton->isHandled()) {
$this->clickedFlowButton->handle();
}
if (!$this->isValid()) {
return $this;
}
return $this->newStepForm();
}
public function getCursor(): FormFlowCursor
{
return $this->cursor;
}
public function getConfig(): FormFlowConfigInterface
{
return $this->config;
}
public function isFinished(): bool
{
return $this->finished;
}
public function getClickedButton(): ButtonFlowInterface|FormInterface|ClickableInterface|null
{
return parent::getClickedButton() ?? $this->clickedFlowButton;
}
private function setClickedFlowButton(mixed $submittedData, FormInterface $form): void
{
if (!\is_array($submittedData)) {
return;
}
foreach ($form as $name => $child) {
if (!\array_key_exists($name, $submittedData)) {
continue;
}
if ($child->count() > 0) {
$this->setClickedFlowButton($submittedData[$name], $child);
if ($this->clickedFlowButton) {
return;
}
continue;
}
if (!$child instanceof ButtonFlowInterface) {
continue;
}
$child->submit($submittedData[$name]);
if ($child->isClicked()) {
$this->clickedFlowButton = $child;
break;
}
}
}
private function moveBackTo(string $step): void
{
$steps = $this->cursor->getSteps();
if (false === $targetIndex = array_search($step, $steps)) {
throw new InvalidArgumentException(\sprintf('Step "%s" does not exist.', $step));
}
$currentStep = $this->cursor->getCurrentStep();
$currentIndex = $this->cursor->getStepIndex();
if ($targetIndex === $currentIndex) {
return;
}
if ($targetIndex > $currentIndex) {
throw new RuntimeException(\sprintf('Cannot move back to step "%s" because it is ahead of the current step "%s".', $step, $currentStep));
}
while ($targetIndex < $currentIndex) {
$this->movePrevious();
$currentIndex = $this->cursor->getStepIndex();
}
if ($targetIndex > $currentIndex) {
throw new RuntimeException(\sprintf('Cannot move back to step "%s" because it is a skipped step.', $step));
}
}
private function move(\Closure $direction): bool
{
$data = $this->getData();
$cursor = $this->cursor;
while (true) {
if (null === $newStep = $direction($cursor)) {
return false;
}
if ($cursor->getCurrentStep() === $newStep) {
return true;
}
$cursor = $cursor->withCurrentStep($newStep);
if (!$this->config->getStep($newStep)->isSkipped($data)) {
break;
}
}
$this->cursor = $cursor;
$this->config->getStepAccessor()->setStep($data, $newStep);
$this->config->getDataStorage()->save($data);
return true;
}
private function isCurrentStepSubmitted(array $submittedData): bool
{
foreach ($this->cursor->getSteps() as $step) {
if (\array_key_exists($step, $submittedData)) {
return $step === $this->cursor->getCurrentStep();
}
}
return true;
}
}