diff --git a/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php new file mode 100644 index 00000000000..66cffd1d482 --- /dev/null +++ b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php @@ -0,0 +1,282 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent; + +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\Storage\StorageInterface; +use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; +use Symfony\UX\TwigComponent\Attribute\PostMount; +use function Symfony\Component\String\u; + +/** + * @author Silas Joisten + * @author Patrick Reimers + * @author Jules Pietri + */ +trait ComponentWithMultiStepFormTrait +{ + use DefaultActionTrait; + use ComponentWithFormTrait; + + #[LiveProp] + public ?string $currentStepName = null; + + /** + * @var string[] + */ + #[LiveProp] + public array $stepNames = []; + + public function hasValidationErrors(): bool + { + return $this->form->isSubmitted() && !$this->form->isValid(); + } + + /** + * @internal + * + * Must be executed after ComponentWithFormTrait::initializeForm(). + */ + #[PostMount(priority: -250)] + public function initialize(): void + { + $this->currentStepName = $this->getStorage()->get( + sprintf('%s_current_step_name', self::prefix()), + $this->formView->vars['current_step_name'], + ); + + $this->form = $this->instantiateForm(); + + $formData = $this->getStorage()->get(sprintf( + '%s_form_values_%s', + self::prefix(), + $this->currentStepName, + )); + + $this->form->setData($formData); + + if ([] === $formData) { + $this->formValues = $this->extractFormValues($this->getFormView()); + } else { + $this->formValues = $formData; + } + + $this->stepNames = $this->formView->vars['steps_names']; + + // Do not move this. The order is important. + $this->formView = null; + } + + #[LiveAction] + public function next(): void + { + $this->submitForm(); + + if ($this->hasValidationErrors()) { + return; + } + + $this->getStorage()->persist( + sprintf('%s_form_values_%s', self::prefix(), $this->currentStepName), + $this->form->getData(), + ); + + $found = false; + $next = null; + + foreach ($this->stepNames as $stepName) { + if ($this->currentStepName === $stepName) { + $found = true; + + continue; + } + + if ($found) { + $next = $stepName; + + break; + } + } + + if (null === $next) { + throw new \RuntimeException('No next forms available.'); + } + + $this->currentStepName = $next; + $this->getStorage()->persist(sprintf('%s_current_step_name', self::prefix()), $this->currentStepName); + + // If we have a next step, we need to resinstantiate the form and reset the form view and values. + $this->form = $this->instantiateForm(); + $this->formView = null; + + $formData = $this->getStorage()->get(sprintf( + '%s_form_values_%s', + self::prefix(), + $this->currentStepName, + )); + + // I really don't understand why we need to do that. But what I understood is extractFormValues creates + // an array of initial values. + if ([] === $formData) { + $this->formValues = $this->extractFormValues($this->getFormView()); + } else { + $this->formValues = $formData; + } + + $this->form->setData($formData); + } + + #[LiveAction] + public function previous(): void + { + $found = false; + $previous = null; + + foreach (array_reverse($this->stepNames) as $stepName) { + if ($this->currentStepName === $stepName) { + $found = true; + + continue; + } + + if ($found) { + $previous = $stepName; + + break; + } + } + + if (null === $previous) { + throw new \RuntimeException('No previous forms available.'); + } + + $this->currentStepName = $previous; + $this->getStorage()->persist(sprintf('%s_current_step_name', self::prefix()), $this->currentStepName); + + $this->form = $this->instantiateForm(); + $this->formView = null; + + $formData = $this->getStorage()->get(sprintf( + '%s_form_values_%s', + self::prefix(), + $this->currentStepName, + )); + + $this->formValues = $formData; + $this->form->setData($formData); + } + + #[ExposeInTemplate] + public function isFirst(): bool + { + return $this->currentStepName === $this->stepNames[array_key_first($this->stepNames)]; + } + + #[ExposeInTemplate] + public function isLast(): bool + { + return $this->currentStepName === $this->stepNames[array_key_last($this->stepNames)]; + } + + #[LiveAction] + public function submit(): void + { + $this->submitForm(); + + if ($this->hasValidationErrors()) { + return; + } + + $this->getStorage()->persist( + sprintf('%s_form_values_%s', self::prefix(), $this->currentStepName), + $this->form->getData(), + ); + + $this->onSubmit(); + } + + abstract public function onSubmit(); + + /** + * @return array + */ + public function getAllData(): array + { + $data = []; + + foreach ($this->stepNames as $stepName) { + $data[$stepName] = $this->getStorage()->get(sprintf( + '%s_form_values_%s', + self::prefix(), + $stepName, + )); + } + + return $data; + } + + public function resetForm(): void + { + foreach ($this->stepNames as $stepName) { + $this->getStorage()->remove(sprintf('%s_form_values_%s', self::prefix(), $stepName)); + } + + $this->getStorage()->remove(sprintf('%s_current_step_name', self::prefix())); + + $this->currentStepName = $this->stepNames[\array_key_first($this->stepNames)]; + $this->form = $this->instantiateForm(); + $this->formView = null; + $this->formValues = $this->extractFormValues($this->getFormView()); + } + + abstract protected function getStorage(): StorageInterface; + + /** + * @return class-string + */ + abstract protected static function formClass(): string; + + abstract protected function getFormFactory(): FormFactoryInterface; + + /** + * @internal + */ + protected function instantiateForm(): FormInterface + { + $options = []; + + if (null !== $this->currentStepName) { + $options['current_step_name'] = $this->currentStepName; + } + + return $this->getFormFactory()->create( + type: static::formClass(), + options: $options, + ); + } + + /** + * @internal + */ + private static function prefix(): string + { + return u(static::class) + ->afterLast('\\') + ->snake() + ->toString(); + } +} diff --git a/src/LiveComponent/src/Form/Type/MultiStepType.php b/src/LiveComponent/src/Form/Type/MultiStepType.php new file mode 100644 index 00000000000..6bb9b62d1ac --- /dev/null +++ b/src/LiveComponent/src/Form/Type/MultiStepType.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\UX\LiveComponent\Form\Type; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @author Silas Joisten + * @author Patrick Reimers + * @author Jules Pietri + */ +final class MultiStepType extends AbstractType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver + ->setDefault('current_step_name', static function (Options $options): string { + return \array_key_first($options['steps']); + }) + ->setRequired('steps'); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $options['steps'][$options['current_step_name']]($builder); + } + + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['current_step_name'] = $options['current_step_name']; + $view->vars['steps_names'] = \array_keys($options['steps']); + } +} diff --git a/src/LiveComponent/src/Storage/SessionStorage.php b/src/LiveComponent/src/Storage/SessionStorage.php new file mode 100644 index 00000000000..f125f315d9b --- /dev/null +++ b/src/LiveComponent/src/Storage/SessionStorage.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\UX\LiveComponent\Storage; + +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * @author Silas Joisten + * @author Patrick Reimers + * @author Jules Pietri + */ +final class SessionStorage implements StorageInterface +{ + public function __construct( + private readonly RequestStack $requestStack, + ) { + } + + public function persist(string $key, mixed $values): void + { + $this->requestStack->getSession()->set($key, $values); + } + + public function remove(string $key): void + { + $this->requestStack->getSession()->remove($key); + } + + public function get(string $key, mixed $default = []): mixed + { + return $this->requestStack->getSession()->get($key, $default); + } +} diff --git a/src/LiveComponent/src/Storage/StorageInterface.php b/src/LiveComponent/src/Storage/StorageInterface.php new file mode 100644 index 00000000000..c1049ad4755 --- /dev/null +++ b/src/LiveComponent/src/Storage/StorageInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\UX\LiveComponent\Storage; + +/** + * @author Silas Joisten + * @author Patrick Reimers + * @author Jules Pietri + */ +interface StorageInterface +{ + public function persist(string $key, mixed $values): void; + + public function remove(string $key): void; + + public function get(string $key, mixed $default = []): mixed; +}