Skip to content

Commit 0d280f9

Browse files
committed
Enhancement: Introduce MultiStep LiveComponent
1 parent 49ec396 commit 0d280f9

File tree

4 files changed

+403
-0
lines changed

4 files changed

+403
-0
lines changed
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the Symfony package.
7+
*
8+
* (c) Fabien Potencier <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Symfony\UX\LiveComponent;
15+
16+
use Symfony\Component\Form\FormFactoryInterface;
17+
use Symfony\Component\Form\FormInterface;
18+
use Symfony\UX\LiveComponent\Attribute\LiveAction;
19+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
20+
use Symfony\UX\LiveComponent\Storage\StorageInterface;
21+
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
22+
use Symfony\UX\TwigComponent\Attribute\PostMount;
23+
use function Symfony\Component\String\u;
24+
25+
/**
26+
* @author Silas Joisten <[email protected]>
27+
* @author Patrick Reimers <[email protected]>
28+
* @author Jules Pietri <[email protected]>
29+
*/
30+
trait ComponentWithMultiStepFormTrait
31+
{
32+
use DefaultActionTrait;
33+
use ComponentWithFormTrait;
34+
35+
#[LiveProp]
36+
public ?string $currentStepName = null;
37+
38+
/**
39+
* @var string[]
40+
*/
41+
#[LiveProp]
42+
public array $stepNames = [];
43+
44+
public function hasValidationErrors(): bool
45+
{
46+
return $this->form->isSubmitted() && !$this->form->isValid();
47+
}
48+
49+
/**
50+
* @internal
51+
*
52+
* Must be executed after ComponentWithFormTrait::initializeForm().
53+
*/
54+
#[PostMount(priority: -250)]
55+
public function initialize(): void
56+
{
57+
$this->currentStepName = $this->getStorage()->get(
58+
sprintf('%s_current_step_name', self::prefix()),
59+
$this->formView->vars['current_step_name'],
60+
);
61+
62+
$this->form = $this->instantiateForm();
63+
64+
$formData = $this->getStorage()->get(sprintf(
65+
'%s_form_values_%s',
66+
self::prefix(),
67+
$this->currentStepName,
68+
));
69+
70+
$this->form->setData($formData);
71+
72+
if ([] === $formData) {
73+
$this->formValues = $this->extractFormValues($this->getFormView());
74+
} else {
75+
$this->formValues = $formData;
76+
}
77+
78+
$this->stepNames = $this->formView->vars['steps_names'];
79+
80+
// Do not move this. The order is important.
81+
$this->formView = null;
82+
}
83+
84+
#[LiveAction]
85+
public function next(): void
86+
{
87+
$this->submitForm();
88+
89+
if ($this->hasValidationErrors()) {
90+
return;
91+
}
92+
93+
$this->getStorage()->persist(
94+
sprintf('%s_form_values_%s', self::prefix(), $this->currentStepName),
95+
$this->form->getData(),
96+
);
97+
98+
$found = false;
99+
$next = null;
100+
101+
foreach ($this->stepNames as $stepName) {
102+
if ($this->currentStepName === $stepName) {
103+
$found = true;
104+
105+
continue;
106+
}
107+
108+
if ($found) {
109+
$next = $stepName;
110+
111+
break;
112+
}
113+
}
114+
115+
if (null === $next) {
116+
throw new \RuntimeException('No next forms available.');
117+
}
118+
119+
$this->currentStepName = $next;
120+
$this->getStorage()->persist(sprintf('%s_current_step_name', self::prefix()), $this->currentStepName);
121+
122+
// If we have a next step, we need to resinstantiate the form and reset the form view and values.
123+
$this->form = $this->instantiateForm();
124+
$this->formView = null;
125+
126+
$formData = $this->getStorage()->get(sprintf(
127+
'%s_form_values_%s',
128+
self::prefix(),
129+
$this->currentStepName,
130+
));
131+
132+
// I really don't understand why we need to do that. But what I understood is extractFormValues creates
133+
// an array of initial values.
134+
if ([] === $formData) {
135+
$this->formValues = $this->extractFormValues($this->getFormView());
136+
} else {
137+
$this->formValues = $formData;
138+
}
139+
140+
$this->form->setData($formData);
141+
}
142+
143+
#[LiveAction]
144+
public function previous(): void
145+
{
146+
$found = false;
147+
$previous = null;
148+
149+
foreach (array_reverse($this->stepNames) as $stepName) {
150+
if ($this->currentStepName === $stepName) {
151+
$found = true;
152+
153+
continue;
154+
}
155+
156+
if ($found) {
157+
$previous = $stepName;
158+
159+
break;
160+
}
161+
}
162+
163+
if (null === $previous) {
164+
throw new \RuntimeException('No previous forms available.');
165+
}
166+
167+
$this->currentStepName = $previous;
168+
$this->getStorage()->persist(sprintf('%s_current_step_name', self::prefix()), $this->currentStepName);
169+
170+
$this->form = $this->instantiateForm();
171+
$this->formView = null;
172+
173+
$formData = $this->getStorage()->get(sprintf(
174+
'%s_form_values_%s',
175+
self::prefix(),
176+
$this->currentStepName,
177+
));
178+
179+
$this->formValues = $formData;
180+
$this->form->setData($formData);
181+
}
182+
183+
#[ExposeInTemplate]
184+
public function isFirst(): bool
185+
{
186+
return $this->currentStepName === $this->stepNames[array_key_first($this->stepNames)];
187+
}
188+
189+
#[ExposeInTemplate]
190+
public function isLast(): bool
191+
{
192+
return $this->currentStepName === $this->stepNames[array_key_last($this->stepNames)];
193+
}
194+
195+
#[LiveAction]
196+
public function submit(): void
197+
{
198+
$this->submitForm();
199+
200+
if ($this->hasValidationErrors()) {
201+
return;
202+
}
203+
204+
$this->getStorage()->persist(
205+
sprintf('%s_form_values_%s', self::prefix(), $this->currentStepName),
206+
$this->form->getData(),
207+
);
208+
209+
$this->onSubmit();
210+
}
211+
212+
abstract public function onSubmit();
213+
214+
/**
215+
* @return array<string, mixed>
216+
*/
217+
public function getAllData(): array
218+
{
219+
$data = [];
220+
221+
foreach ($this->stepNames as $stepName) {
222+
$data[$stepName] = $this->getStorage()->get(sprintf(
223+
'%s_form_values_%s',
224+
self::prefix(),
225+
$stepName,
226+
));
227+
}
228+
229+
return $data;
230+
}
231+
232+
public function resetForm(): void
233+
{
234+
foreach ($this->stepNames as $stepName) {
235+
$this->getStorage()->remove(sprintf('%s_form_values_%s', self::prefix(), $stepName));
236+
}
237+
238+
$this->getStorage()->remove(sprintf('%s_current_step_name', self::prefix()));
239+
240+
$this->currentStepName = $this->stepNames[\array_key_first($this->stepNames)];
241+
$this->form = $this->instantiateForm();
242+
$this->formView = null;
243+
$this->formValues = $this->extractFormValues($this->getFormView());
244+
}
245+
246+
abstract protected function getStorage(): StorageInterface;
247+
248+
/**
249+
* @return class-string<FormInterface>
250+
*/
251+
abstract protected static function formClass(): string;
252+
253+
abstract protected function getFormFactory(): FormFactoryInterface;
254+
255+
/**
256+
* @internal
257+
*/
258+
protected function instantiateForm(): FormInterface
259+
{
260+
$options = [];
261+
262+
if (null !== $this->currentStepName) {
263+
$options['current_step_name'] = $this->currentStepName;
264+
}
265+
266+
return $this->getFormFactory()->create(
267+
type: static::formClass(),
268+
options: $options,
269+
);
270+
}
271+
272+
/**
273+
* @internal
274+
*/
275+
private static function prefix(): string
276+
{
277+
return u(static::class)
278+
->afterLast('\\')
279+
->snake()
280+
->toString();
281+
}
282+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Symfony\UX\LiveComponent\Form\Type;
15+
16+
use Symfony\Component\Form\AbstractType;
17+
use Symfony\Component\Form\FormBuilderInterface;
18+
use Symfony\Component\Form\FormInterface;
19+
use Symfony\Component\Form\FormView;
20+
use Symfony\Component\OptionsResolver\Options;
21+
use Symfony\Component\OptionsResolver\OptionsResolver;
22+
23+
/**
24+
* @author Silas Joisten <[email protected]>
25+
* @author Patrick Reimers <[email protected]>
26+
* @author Jules Pietri <[email protected]>
27+
*/
28+
final class MultiStepType extends AbstractType
29+
{
30+
public function configureOptions(OptionsResolver $resolver): void
31+
{
32+
$resolver
33+
->setDefault('current_step_name', static function (Options $options): string {
34+
return \array_key_first($options['steps']);
35+
})
36+
->setRequired('steps');
37+
}
38+
39+
public function buildForm(FormBuilderInterface $builder, array $options): void
40+
{
41+
$options['steps'][$options['current_step_name']]($builder);
42+
}
43+
44+
public function buildView(FormView $view, FormInterface $form, array $options): void
45+
{
46+
$view->vars['current_step_name'] = $options['current_step_name'];
47+
$view->vars['steps_names'] = \array_keys($options['steps']);
48+
}
49+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace Symfony\UX\LiveComponent\Storage;
15+
16+
use Symfony\Component\HttpFoundation\RequestStack;
17+
18+
/**
19+
* @author Silas Joisten <[email protected]>
20+
* @author Patrick Reimers <[email protected]>
21+
* @author Jules Pietri <[email protected]>
22+
*/
23+
final class SessionStorage implements StorageInterface
24+
{
25+
public function __construct(
26+
private readonly RequestStack $requestStack,
27+
) {
28+
}
29+
30+
public function persist(string $key, mixed $values): void
31+
{
32+
$this->requestStack->getSession()->set($key, $values);
33+
}
34+
35+
public function remove(string $key): void
36+
{
37+
$this->requestStack->getSession()->remove($key);
38+
}
39+
40+
public function get(string $key, mixed $default = []): mixed
41+
{
42+
return $this->requestStack->getSession()->get($key, $default);
43+
}
44+
}

0 commit comments

Comments
 (0)