diff --git a/src/LiveComponent/composer.json b/src/LiveComponent/composer.json index 7359a8b6eef..b644a75104d 100644 --- a/src/LiveComponent/composer.json +++ b/src/LiveComponent/composer.json @@ -51,6 +51,7 @@ "symfony/serializer": "^5.4|^6.0|^7.0", "symfony/twig-bundle": "^5.4|^6.0|^7.0", "symfony/validator": "^5.4|^6.0|^7.0", + "symfony/string": "^5.4|^6.0|^7.0", "zenstruck/browser": "^1.2.0", "zenstruck/foundry": "^2.0" }, diff --git a/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php new file mode 100644 index 00000000000..5335701d61b --- /dev/null +++ b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php @@ -0,0 +1,312 @@ + + * + * 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; + +/** + * Trait for managing multi-step forms in LiveComponent. + * + * This trait simplifies the implementation of multi-step forms by handling + * step transitions, form validation, data persistence, and state management. + * + * @author Silas Joisten + * @author Patrick Reimers + * @author Jules Pietri + */ +trait ComponentWithMultiStepFormTrait +{ + use ComponentWithFormTrait; + use DefaultActionTrait; + + #[LiveProp] + public ?string $currentStepName = null; + + /** + * @var string[] + */ + #[LiveProp] + public array $stepNames = []; + + /** + * Checks if the current form has validation errors. + */ + public function hasValidationErrors(): bool + { + return $this->form->isSubmitted() && !$this->form->isValid(); + } + + /** + * @internal + * + * Initializes the form and restores the state from storage. + * + * This method 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); + + $this->formValues = [] === $formData + ? $this->extractFormValues($this->getFormView()) + : $formData; + + $this->stepNames = $this->formView->vars['steps_names']; + + // Do not move this. The order is important. + $this->formView = null; + } + + /** + * Advances to the next step in the form. + * + * Validates the current step, saves its data, and moves to the next step. + * Throws a RuntimeException if no next step is available. + */ + #[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)); + + $this->formValues = [] === $formData + ? $this->extractFormValues($this->getFormView()) + : $formData; + + $this->form->setData($formData); + } + + /** + * Moves to the previous step in the form. + * + * Retrieves the previous step's data and updates the form state. + * Throws a RuntimeException if no previous step is available. + */ + #[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); + } + + /** + * Checks if the current step is the first step. + * + * @return bool true if the current step is the first; false otherwise + */ + #[ExposeInTemplate] + public function isFirst(): bool + { + return $this->currentStepName === $this->stepNames[array_key_first($this->stepNames)]; + } + + /** + * Checks if the current step is the last step. + * + * @return bool true if the current step is the last; false otherwise + */ + #[ExposeInTemplate] + public function isLast(): bool + { + return $this->currentStepName === $this->stepNames[array_key_last($this->stepNames)]; + } + + /** + * Submits the form and triggers the `onSubmit` callback if valid. + */ + #[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 method to be implemented by the component for custom submission logic. + */ + abstract public function onSubmit(); + + /** + * Retrieves all data from all steps. + * + * @return array an associative array of step names and their data + */ + 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; + } + + /** + * Resets the form, clearing all stored data and returning to the first step. + */ + 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 method to retrieve the storage implementation. + * + * @return StorageInterface the storage instance + */ + abstract protected function getStorage(): StorageInterface; + + /** + * Abstract method to specify the form class for the component. + * + * @return class-string the form class name + */ + abstract protected static function formClass(): string; + + /** + * Abstract method to retrieve the form factory instance. + * + * @return FormFactoryInterface the form factory + */ + abstract protected function getFormFactory(): FormFactoryInterface; + + /** + * @internal + * + * Instantiates the form for the current step + * + * @return FormInterface the form instance + */ + protected function instantiateForm(): FormInterface + { + $options = []; + + if (null !== $this->currentStepName) { + $options['current_step_name'] = $this->currentStepName; + } + + return $this->getFormFactory()->create(static::formClass(), null, $options); + } + + /** + * @internal + * + * Generates a unique prefix based on the component's class name + * + * @return string the generated prefix in snake case + */ + 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..46e43bae417 --- /dev/null +++ b/src/LiveComponent/src/Form/Type/MultiStepType.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +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..a30e24ee7f1 --- /dev/null +++ b/src/LiveComponent/src/Storage/SessionStorage.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Storage; + +use Symfony\Component\HttpFoundation\RequestStack; + +/** + * Implementation of the StorageInterface using Symfony's session mechanism. + * + * This class provides a session-based storage solution for managing data + * persistence in Symfony UX LiveComponent. It leverages the Symfony + * `RequestStack` to access the session and perform operations such as + * storing, retrieving, and removing data. + * + * Common use cases include persisting component state, such as form data + * or multistep workflow progress, across user interactions. + * + * @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..286f962dff1 --- /dev/null +++ b/src/LiveComponent/src/Storage/StorageInterface.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Storage; + +/** + * Interface for a storage mechanism used in Symfony UX LiveComponent. + * + * This interface provides methods for persisting, retrieving, and removing + * data, ensuring a consistent API for managing state across components. It + * is essential for features like multistep forms where data needs to persist + * between user interactions. + * + * @author Silas Joisten + * @author Patrick Reimers + * @author Jules Pietri + */ +interface StorageInterface +{ + /** + * Persists a value in the storage using the specified key. + * + * This method is used to save the state of a component or any other + * relevant data that needs to persist across requests or interactions. + * + * @param string $key the unique identifier for the data to store + * @param mixed $values the value to be stored + */ + public function persist(string $key, mixed $values): void; + + /** + * Removes an entry from the storage based on the specified key. + * + * This method is useful for cleaning up data that is no longer needed, + * such as resetting a form or clearing cached values. + * + * @param string $key the unique identifier for the data to remove + */ + public function remove(string $key): void; + + /** + * Retrieves a value from the storage by its key. + * + * If the specified key does not exist in the storage, this method returns + * a default value instead. This is commonly used to fetch saved state or + * configuration for a component. + * + * @param string $key the unique identifier for the data to retrieve + * @param mixed $default The default value to return if the key is not found. + * Defaults to an empty array. + * + * @return mixed the value associated with the specified key or the default value + */ + public function get(string $key, mixed $default = []): mixed; +} diff --git a/src/LiveComponent/tests/Unit/Form/Type/MultiStepTypeTest.php b/src/LiveComponent/tests/Unit/Form/Type/MultiStepTypeTest.php new file mode 100644 index 00000000000..5163352f7d8 --- /dev/null +++ b/src/LiveComponent/tests/Unit/Form/Type/MultiStepTypeTest.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Unit\Form\Type; + +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\Test\TypeTestCase; +use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; +use Symfony\UX\LiveComponent\Form\Type\MultiStepType; + +/** + * @author Silas Joisten + */ +final class MultiStepTypeTest extends TypeTestCase +{ + public function testConfigureOptionsWithoutStepsThrowsException(): void + { + self::expectException(MissingOptionsException::class); + + $this->factory->create(MultiStepType::class); + } + + public function testConfigureOptionsWithStepsSetsDefaultForCurrentStepName(): void + { + $form = $this->factory->create(MultiStepType::class, [], [ + 'steps' => [ + 'general' => static function (): void {}, + 'contact' => static function (): void {}, + 'newsletter' => static function (): void {}, + ], + ]); + + self::assertSame('general', $form->createView()->vars['current_step_name']); + } + + public function testBuildViewHasStepNames(): void + { + $form = $this->factory->create(MultiStepType::class, [], [ + 'steps' => [ + 'general' => static function (): void {}, + 'contact' => static function (): void {}, + 'newsletter' => static function (): void {}, + ], + ]); + + self::assertSame(['general', 'contact', 'newsletter'], $form->createView()->vars['steps_names']); + } + + public function testFormOnlyHasCurrentStepForm(): void + { + $form = $this->factory->create(MultiStepType::class, [], [ + 'steps' => [ + 'general' => static function (FormBuilderInterface $builder): void { + $builder + ->add('firstName', TextType::class) + ->add('lastName', TextType::class); + }, + 'contact' => static function (FormBuilderInterface $builder): void { + $builder + ->add('address', TextType::class) + ->add('city', TextType::class); + }, + 'newsletter' => static function (): void {}, + ], + ]); + + self::assertArrayHasKey('firstName', $form->createView()->children); + self::assertArrayHasKey('lastName', $form->createView()->children); + self::assertArrayNotHasKey('address', $form->createView()->children); + self::assertArrayNotHasKey('city', $form->createView()->children); + } +}