From 0d280f915b91eb41da299da69784b94ebb5d3b35 Mon Sep 17 00:00:00 2001 From: Silas Joisten Date: Sat, 7 Dec 2024 22:04:21 +0100 Subject: [PATCH 01/12] Enhancement: Introduce MultiStep LiveComponent --- .../src/ComponentWithMultiStepFormTrait.php | 282 ++++++++++++++++++ .../src/Form/Type/MultiStepType.php | 49 +++ .../src/Storage/SessionStorage.php | 44 +++ .../src/Storage/StorageInterface.php | 28 ++ 4 files changed, 403 insertions(+) create mode 100644 src/LiveComponent/src/ComponentWithMultiStepFormTrait.php create mode 100644 src/LiveComponent/src/Form/Type/MultiStepType.php create mode 100644 src/LiveComponent/src/Storage/SessionStorage.php create mode 100644 src/LiveComponent/src/Storage/StorageInterface.php 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; +} From 20ef568d2732f0c314f5cc8ad85ba7540721c5f0 Mon Sep 17 00:00:00 2001 From: Silas Joisten Date: Sat, 7 Dec 2024 22:26:15 +0100 Subject: [PATCH 02/12] Fix --- .../src/ComponentWithMultiStepFormTrait.php | 29 ++++++++++--------- .../src/Form/Type/MultiStepType.php | 9 +++--- .../src/Storage/SessionStorage.php | 5 ++-- .../src/Storage/StorageInterface.php | 5 ++-- 4 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php index 66cffd1d482..5bde7863f51 100644 --- a/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php +++ b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php @@ -20,6 +20,7 @@ use Symfony\UX\LiveComponent\Storage\StorageInterface; use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; use Symfony\UX\TwigComponent\Attribute\PostMount; + use function Symfony\Component\String\u; /** @@ -29,8 +30,8 @@ */ trait ComponentWithMultiStepFormTrait { - use DefaultActionTrait; use ComponentWithFormTrait; + use DefaultActionTrait; #[LiveProp] public ?string $currentStepName = null; @@ -49,19 +50,19 @@ public function hasValidationErrors(): bool /** * @internal * - * Must be executed after ComponentWithFormTrait::initializeForm(). + * 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()), + \sprintf('%s_current_step_name', self::prefix()), $this->formView->vars['current_step_name'], ); $this->form = $this->instantiateForm(); - $formData = $this->getStorage()->get(sprintf( + $formData = $this->getStorage()->get(\sprintf( '%s_form_values_%s', self::prefix(), $this->currentStepName, @@ -91,7 +92,7 @@ public function next(): void } $this->getStorage()->persist( - sprintf('%s_form_values_%s', self::prefix(), $this->currentStepName), + \sprintf('%s_form_values_%s', self::prefix(), $this->currentStepName), $this->form->getData(), ); @@ -117,13 +118,13 @@ public function next(): void } $this->currentStepName = $next; - $this->getStorage()->persist(sprintf('%s_current_step_name', self::prefix()), $this->currentStepName); + $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( + $formData = $this->getStorage()->get(\sprintf( '%s_form_values_%s', self::prefix(), $this->currentStepName, @@ -165,12 +166,12 @@ public function previous(): void } $this->currentStepName = $previous; - $this->getStorage()->persist(sprintf('%s_current_step_name', self::prefix()), $this->currentStepName); + $this->getStorage()->persist(\sprintf('%s_current_step_name', self::prefix()), $this->currentStepName); $this->form = $this->instantiateForm(); $this->formView = null; - $formData = $this->getStorage()->get(sprintf( + $formData = $this->getStorage()->get(\sprintf( '%s_form_values_%s', self::prefix(), $this->currentStepName, @@ -202,7 +203,7 @@ public function submit(): void } $this->getStorage()->persist( - sprintf('%s_form_values_%s', self::prefix(), $this->currentStepName), + \sprintf('%s_form_values_%s', self::prefix(), $this->currentStepName), $this->form->getData(), ); @@ -219,7 +220,7 @@ public function getAllData(): array $data = []; foreach ($this->stepNames as $stepName) { - $data[$stepName] = $this->getStorage()->get(sprintf( + $data[$stepName] = $this->getStorage()->get(\sprintf( '%s_form_values_%s', self::prefix(), $stepName, @@ -232,12 +233,12 @@ public function getAllData(): array 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_form_values_%s', self::prefix(), $stepName)); } - $this->getStorage()->remove(sprintf('%s_current_step_name', self::prefix())); + $this->getStorage()->remove(\sprintf('%s_current_step_name', self::prefix())); - $this->currentStepName = $this->stepNames[\array_key_first($this->stepNames)]; + $this->currentStepName = $this->stepNames[array_key_first($this->stepNames)]; $this->form = $this->instantiateForm(); $this->formView = null; $this->formValues = $this->extractFormValues($this->getFormView()); diff --git a/src/LiveComponent/src/Form/Type/MultiStepType.php b/src/LiveComponent/src/Form/Type/MultiStepType.php index 6bb9b62d1ac..55998a0e5f5 100644 --- a/src/LiveComponent/src/Form/Type/MultiStepType.php +++ b/src/LiveComponent/src/Form/Type/MultiStepType.php @@ -1,5 +1,8 @@ setDefault('current_step_name', static function (Options $options): string { - return \array_key_first($options['steps']); + return array_key_first($options['steps']); }) ->setRequired('steps'); } @@ -44,6 +45,6 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 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']); + $view->vars['steps_names'] = array_keys($options['steps']); } } diff --git a/src/LiveComponent/src/Storage/SessionStorage.php b/src/LiveComponent/src/Storage/SessionStorage.php index f125f315d9b..6aba4b6bd8a 100644 --- a/src/LiveComponent/src/Storage/SessionStorage.php +++ b/src/LiveComponent/src/Storage/SessionStorage.php @@ -1,5 +1,8 @@ Date: Sat, 7 Dec 2024 22:27:48 +0100 Subject: [PATCH 03/12] Fix --- src/LiveComponent/src/Form/Type/MultiStepType.php | 1 - src/LiveComponent/src/Storage/SessionStorage.php | 1 - src/LiveComponent/src/Storage/StorageInterface.php | 1 - 3 files changed, 3 deletions(-) diff --git a/src/LiveComponent/src/Form/Type/MultiStepType.php b/src/LiveComponent/src/Form/Type/MultiStepType.php index 55998a0e5f5..879ac812e48 100644 --- a/src/LiveComponent/src/Form/Type/MultiStepType.php +++ b/src/LiveComponent/src/Form/Type/MultiStepType.php @@ -1,6 +1,5 @@ Date: Sun, 8 Dec 2024 10:05:19 +0100 Subject: [PATCH 04/12] Fix --- .../src/ComponentWithMultiStepFormTrait.php | 134 +++++++++++------- .../src/Storage/SessionStorage.php | 12 +- .../src/Storage/StorageInterface.php | 39 ++++- 3 files changed, 132 insertions(+), 53 deletions(-) diff --git a/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php index 5bde7863f51..79d9d81b1ce 100644 --- a/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php +++ b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php @@ -24,9 +24,16 @@ use function Symfony\Component\String\u; /** + * Trait for managing multistep forms in Symfony UX LiveComponent. + * + * This trait simplifies the implementation of multistep forms by handling + * step transitions, form validation, data persistence, and state management. + * It provides a structured API for developers to integrate multistep forms + * into their components with minimal boilerplate. + * * @author Silas Joisten * @author Patrick Reimers - * @author Jules Pietri + * @author Jules Pietri */ trait ComponentWithMultiStepFormTrait { @@ -42,6 +49,9 @@ trait ComponentWithMultiStepFormTrait #[LiveProp] public array $stepNames = []; + /** + * Checks if the current form has validation errors. + */ public function hasValidationErrors(): bool { return $this->form->isSubmitted() && !$this->form->isValid(); @@ -50,31 +60,24 @@ public function hasValidationErrors(): bool /** * @internal * - * Must be executed after ComponentWithFormTrait::initializeForm() + * 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->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, - )); + $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->formValues = [] === $formData + ? $this->extractFormValues($this->getFormView()) + : $formData; $this->stepNames = $this->formView->vars['steps_names']; @@ -82,6 +85,12 @@ public function initialize(): void $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 { @@ -91,10 +100,7 @@ public function next(): void return; } - $this->getStorage()->persist( - \sprintf('%s_form_values_%s', self::prefix(), $this->currentStepName), - $this->form->getData(), - ); + $this->getStorage()->persist(\sprintf('%s_form_values_%s', self::prefix(), $this->currentStepName), $this->form->getData()); $found = false; $next = null; @@ -124,23 +130,21 @@ public function next(): void $this->form = $this->instantiateForm(); $this->formView = null; - $formData = $this->getStorage()->get(\sprintf( - '%s_form_values_%s', - self::prefix(), - $this->currentStepName, - )); + $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->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 { @@ -181,18 +185,31 @@ public function previous(): void $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 { @@ -202,34 +219,35 @@ public function submit(): void return; } - $this->getStorage()->persist( - \sprintf('%s_form_values_%s', self::prefix(), $this->currentStepName), - $this->form->getData(), - ); + $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(); /** - * @return array + * 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, - )); + $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) { @@ -244,17 +262,33 @@ public function resetForm(): void $this->formValues = $this->extractFormValues($this->getFormView()); } + /** + * Abstract method to retrieve the storage implementation. + * + * @return StorageInterface The storage instance. + */ abstract protected function getStorage(): StorageInterface; /** - * @return class-string + * 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 { @@ -264,20 +298,18 @@ protected function instantiateForm(): FormInterface $options['current_step_name'] = $this->currentStepName; } - return $this->getFormFactory()->create( - type: static::formClass(), - options: $options, - ); + 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(); + return u(static::class)->afterLast('\\')->snake()->toString(); } } diff --git a/src/LiveComponent/src/Storage/SessionStorage.php b/src/LiveComponent/src/Storage/SessionStorage.php index d7a85105265..146a356781b 100644 --- a/src/LiveComponent/src/Storage/SessionStorage.php +++ b/src/LiveComponent/src/Storage/SessionStorage.php @@ -16,9 +16,19 @@ 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 + * @author Jules Pietri */ final class SessionStorage implements StorageInterface { diff --git a/src/LiveComponent/src/Storage/StorageInterface.php b/src/LiveComponent/src/Storage/StorageInterface.php index e13294e5457..0ed07c28d54 100644 --- a/src/LiveComponent/src/Storage/StorageInterface.php +++ b/src/LiveComponent/src/Storage/StorageInterface.php @@ -14,15 +14,52 @@ 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 + * @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; } From c886e65d4a72e3a235c47fecbb8454d34fecd3e9 Mon Sep 17 00:00:00 2001 From: Silas Joisten Date: Sun, 8 Dec 2024 10:06:30 +0100 Subject: [PATCH 05/12] Require String component --- src/LiveComponent/composer.json | 1 + 1 file changed, 1 insertion(+) 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" }, From 06df934f622eee11f4f7fdd58be249bf2bbff5ac Mon Sep 17 00:00:00 2001 From: Silas Joisten Date: Sun, 8 Dec 2024 10:08:51 +0100 Subject: [PATCH 06/12] Fix --- .../src/ComponentWithMultiStepFormTrait.php | 20 +++++++++---------- .../src/Storage/StorageInterface.php | 10 +++++----- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php index 79d9d81b1ce..f6e8f43c523 100644 --- a/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php +++ b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php @@ -188,7 +188,7 @@ public function previous(): void /** * Checks if the current step is the first step. * - * @return bool True if the current step is the first; false otherwise. + * @return bool true if the current step is the first; false otherwise */ #[ExposeInTemplate] public function isFirst(): bool @@ -199,7 +199,7 @@ public function isFirst(): bool /** * Checks if the current step is the last step. * - * @return bool True if the current step is the last; false otherwise. + * @return bool true if the current step is the last; false otherwise */ #[ExposeInTemplate] public function isLast(): bool @@ -232,7 +232,7 @@ abstract public function onSubmit(); /** * Retrieves all data from all steps. * - * @return array An associative array of step names and their data. + * @return array an associative array of step names and their data */ public function getAllData(): array { @@ -265,30 +265,30 @@ public function resetForm(): void /** * Abstract method to retrieve the storage implementation. * - * @return StorageInterface The storage instance. + * @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. + * @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. + * @return FormFactoryInterface the form factory */ abstract protected function getFormFactory(): FormFactoryInterface; /** * @internal * - * Instantiates the form for the current step. + * Instantiates the form for the current step * - * @return FormInterface The form instance. + * @return FormInterface the form instance */ protected function instantiateForm(): FormInterface { @@ -304,9 +304,9 @@ protected function instantiateForm(): FormInterface /** * @internal * - * Generates a unique prefix based on the component's class name. + * Generates a unique prefix based on the component's class name * - * @return string The generated prefix in snake case. + * @return string the generated prefix in snake case */ private static function prefix(): string { diff --git a/src/LiveComponent/src/Storage/StorageInterface.php b/src/LiveComponent/src/Storage/StorageInterface.php index 0ed07c28d54..286f962dff1 100644 --- a/src/LiveComponent/src/Storage/StorageInterface.php +++ b/src/LiveComponent/src/Storage/StorageInterface.php @@ -33,8 +33,8 @@ interface StorageInterface * 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. + * @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; @@ -44,7 +44,7 @@ public function persist(string $key, mixed $values): void; * 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. + * @param string $key the unique identifier for the data to remove */ public function remove(string $key): void; @@ -55,11 +55,11 @@ public function remove(string $key): void; * 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 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. + * @return mixed the value associated with the specified key or the default value */ public function get(string $key, mixed $default = []): mixed; } From 3c43ca535ea549c8e7813e5ec5dc841271ea89b2 Mon Sep 17 00:00:00 2001 From: Silas Joisten Date: Sun, 8 Dec 2024 10:27:21 +0100 Subject: [PATCH 07/12] Adds test for FormType --- .../Unit/Form/Type/MultiStepTypeTest.php | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/LiveComponent/tests/Unit/Form/Type/MultiStepTypeTest.php 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); + } +} From a3a351424163e46320e6c7cd61cc18b57dd60388 Mon Sep 17 00:00:00 2001 From: Silas Joisten Date: Wed, 11 Dec 2024 08:00:38 +0100 Subject: [PATCH 08/12] Update src/LiveComponent/src/ComponentWithMultiStepFormTrait.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Simon André --- src/LiveComponent/src/ComponentWithMultiStepFormTrait.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php index f6e8f43c523..d1fcaeb0432 100644 --- a/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php +++ b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php @@ -1,6 +1,5 @@ Date: Wed, 11 Dec 2024 08:17:54 +0100 Subject: [PATCH 09/12] Update src/LiveComponent/src/ComponentWithMultiStepFormTrait.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Simon André --- src/LiveComponent/src/ComponentWithMultiStepFormTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php index d1fcaeb0432..9a74f61dbdb 100644 --- a/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php +++ b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php @@ -23,7 +23,7 @@ use function Symfony\Component\String\u; /** - * Trait for managing multistep forms in Symfony UX LiveComponent. + * Trait for managing multi-step forms in LiveComponent. * * This trait simplifies the implementation of multistep forms by handling * step transitions, form validation, data persistence, and state management. From b2ea6ed595c270f793aaa859802ecaf5ac595ad3 Mon Sep 17 00:00:00 2001 From: Silas Joisten Date: Wed, 11 Dec 2024 08:18:02 +0100 Subject: [PATCH 10/12] Update src/LiveComponent/src/ComponentWithMultiStepFormTrait.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Simon André --- src/LiveComponent/src/ComponentWithMultiStepFormTrait.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php index 9a74f61dbdb..5335701d61b 100644 --- a/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php +++ b/src/LiveComponent/src/ComponentWithMultiStepFormTrait.php @@ -25,10 +25,8 @@ /** * Trait for managing multi-step forms in LiveComponent. * - * This trait simplifies the implementation of multistep forms by handling + * This trait simplifies the implementation of multi-step forms by handling * step transitions, form validation, data persistence, and state management. - * It provides a structured API for developers to integrate multistep forms - * into their components with minimal boilerplate. * * @author Silas Joisten * @author Patrick Reimers From a67443fded48e7895ed45009a7cce39228b286c1 Mon Sep 17 00:00:00 2001 From: Silas Joisten Date: Wed, 11 Dec 2024 08:20:49 +0100 Subject: [PATCH 11/12] Update src/LiveComponent/src/Storage/SessionStorage.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Simon André --- src/LiveComponent/src/Storage/SessionStorage.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/LiveComponent/src/Storage/SessionStorage.php b/src/LiveComponent/src/Storage/SessionStorage.php index 146a356781b..a30e24ee7f1 100644 --- a/src/LiveComponent/src/Storage/SessionStorage.php +++ b/src/LiveComponent/src/Storage/SessionStorage.php @@ -1,6 +1,5 @@ Date: Wed, 11 Dec 2024 08:44:31 +0100 Subject: [PATCH 12/12] Update src/LiveComponent/src/Form/Type/MultiStepType.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Simon André --- src/LiveComponent/src/Form/Type/MultiStepType.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/LiveComponent/src/Form/Type/MultiStepType.php b/src/LiveComponent/src/Form/Type/MultiStepType.php index 879ac812e48..46e43bae417 100644 --- a/src/LiveComponent/src/Form/Type/MultiStepType.php +++ b/src/LiveComponent/src/Form/Type/MultiStepType.php @@ -1,6 +1,5 @@