Skip to content

Commit 2f81d34

Browse files
committed
Add FormFlow component
1 parent 5276de0 commit 2f81d34

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+2745
-2
lines changed

src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException;
1818
use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface;
1919
use Symfony\Component\Form\Extension\Core\Type\FormType;
20+
use Symfony\Component\Form\Flow\FormFlowBuilderInterface;
21+
use Symfony\Component\Form\Flow\FormFlowInterface;
22+
use Symfony\Component\Form\Flow\FormFlowTypeInterface;
2023
use Symfony\Component\Form\FormBuilderInterface;
2124
use Symfony\Component\Form\FormFactoryInterface;
2225
use Symfony\Component\Form\FormInterface;
@@ -345,6 +348,8 @@ protected function createAccessDeniedException(string $message = 'Access Denied.
345348

346349
/**
347350
* Creates and returns a Form instance from the type of the form.
351+
*
352+
* @return ($type is class-string<FormFlowTypeInterface> ? FormFlowInterface : FormInterface)
348353
*/
349354
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
350355
{
@@ -353,6 +358,8 @@ protected function createForm(string $type, mixed $data = null, array $options =
353358

354359
/**
355360
* Creates and returns a form builder instance.
361+
*
362+
* @return ($type is class-string<FormFlowTypeInterface> ? FormFlowBuilderInterface : FormBuilderInterface)
356363
*/
357364
protected function createFormBuilder(mixed $data = null, array $options = []): FormBuilderInterface
358365
{

src/Symfony/Bundle/FrameworkBundle/Resources/config/form.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use Symfony\Component\Form\Extension\DependencyInjection\DependencyInjectionExtension;
2525
use Symfony\Component\Form\Extension\HtmlSanitizer\Type\TextTypeHtmlSanitizerExtension;
2626
use Symfony\Component\Form\Extension\HttpFoundation\HttpFoundationRequestHandler;
27+
use Symfony\Component\Form\Extension\HttpFoundation\Type\FormFlowTypeSessionDataStorageExtension;
2728
use Symfony\Component\Form\Extension\HttpFoundation\Type\FormTypeHttpFoundationExtension;
2829
use Symfony\Component\Form\Extension\Validator\Type\FormTypeValidatorExtension;
2930
use Symfony\Component\Form\Extension\Validator\Type\RepeatedTypeValidatorExtension;
@@ -123,6 +124,10 @@
123124
->args([service('form.type_extension.form.request_handler')])
124125
->tag('form.type_extension')
125126

127+
->set('form.type_extension.form.flow.session_data_storage', FormFlowTypeSessionDataStorageExtension::class)
128+
->args([service('request_stack')->ignoreOnInvalid()])
129+
->tag('form.type_extension')
130+
126131
->set('form.type_extension.form.request_handler', HttpFoundationRequestHandler::class)
127132
->args([service('form.server_params')])
128133

src/Symfony/Bundle/FrameworkBundle/composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"symfony/dom-crawler": "^6.4|^7.0",
4646
"symfony/dotenv": "^6.4|^7.0",
4747
"symfony/polyfill-intl-icu": "~1.0",
48-
"symfony/form": "^6.4|^7.0",
48+
"symfony/form": "^7.3",
4949
"symfony/expression-language": "^6.4|^7.0",
5050
"symfony/html-sanitizer": "^6.4|^7.0",
5151
"symfony/http-client": "^6.4|^7.0",
@@ -88,7 +88,7 @@
8888
"symfony/dotenv": "<6.4",
8989
"symfony/dom-crawler": "<6.4",
9090
"symfony/http-client": "<6.4",
91-
"symfony/form": "<6.4",
91+
"symfony/form": "<7.3",
9292
"symfony/json-streamer": ">=7.4",
9393
"symfony/lock": "<6.4",
9494
"symfony/mailer": "<6.4",

src/Symfony/Component/Form/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.4
5+
---
6+
7+
* Add `FormFlow` component for multistep forms management
8+
49
7.3
510
---
611

src/Symfony/Component/Form/Extension/Core/CoreExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ protected function loadTypes(): array
7878
new Type\TelType(),
7979
new Type\ColorType($this->translator),
8080
new Type\WeekType(),
81+
new Type\FormFlowType(),
8182
];
8283
}
8384

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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+
namespace Symfony\Component\Form\Extension\Core\Type;
13+
14+
use Symfony\Component\Form\AbstractType;
15+
use Symfony\Component\Form\Flow\ActionButtonTypeInterface;
16+
use Symfony\Component\OptionsResolver\Options;
17+
use Symfony\Component\OptionsResolver\OptionsResolver;
18+
19+
/**
20+
* An action-based submit button for a form flow.
21+
*
22+
* @author Yonel Ceruto <open@yceruto.dev>
23+
*/
24+
class FormFlowActionType extends AbstractType implements ActionButtonTypeInterface
25+
{
26+
public function configureOptions(OptionsResolver $resolver): void
27+
{
28+
$resolver->define('action')
29+
->info('The action name of the button')
30+
->default('')
31+
->allowedTypes('string');
32+
33+
$resolver->define('handler')
34+
->info('A callable that will be called when this button is clicked')
35+
->default(null)
36+
->allowedTypes('null', 'callable');
37+
38+
$resolver->define('include_if')
39+
->info('Decide whether to include this button in the current form')
40+
->default(null)
41+
->allowedTypes('null', 'callable');
42+
43+
$resolver->define('clear_submission')
44+
->info('Whether the submitted data will be cleared when this button is clicked')
45+
->default(function (Options $options) {
46+
return 'reset' === $options['action'] || 'back' === $options['action'];
47+
})
48+
->allowedTypes('bool');
49+
50+
$resolver->setDefault('validate', function (Options $options) {
51+
return !$options['clear_submission'];
52+
});
53+
54+
$resolver->setDefault('validation_groups', function (Options $options) {
55+
return $options['clear_submission'] ? false : null;
56+
});
57+
}
58+
59+
public function getParent(): string
60+
{
61+
return SubmitType::class;
62+
}
63+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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+
namespace Symfony\Component\Form\Extension\Core\Type;
13+
14+
use Symfony\Component\Form\AbstractType;
15+
use Symfony\Component\Form\Flow\FormFlowCursor;
16+
use Symfony\Component\Form\FormBuilderInterface;
17+
use Symfony\Component\OptionsResolver\OptionsResolver;
18+
19+
/**
20+
* A navigator type that defines default actions to interact with a form flow.
21+
*
22+
* @author Yonel Ceruto <open@yceruto.dev>
23+
*/
24+
class FormFlowNavigatorType extends AbstractType
25+
{
26+
public function buildForm(FormBuilderInterface $builder, array $options): void
27+
{
28+
$builder->add('back', FormFlowActionType::class, [
29+
'action' => 'back',
30+
'include_if' => fn (FormFlowCursor $cursor): bool => $cursor->canMoveBack(),
31+
]);
32+
33+
$builder->add('next', FormFlowActionType::class, [
34+
'action' => 'next',
35+
'include_if' => fn (FormFlowCursor $cursor): bool => $cursor->canMoveNext(),
36+
]);
37+
38+
$builder->add('finish', FormFlowActionType::class, [
39+
'action' => 'finish',
40+
'include_if' => fn (FormFlowCursor $cursor): bool => $cursor->isLastStep(),
41+
]);
42+
}
43+
44+
public function configureOptions(OptionsResolver $resolver): void
45+
{
46+
$resolver->setDefaults([
47+
'label' => false,
48+
'mapped' => false,
49+
'priority' => -100,
50+
]);
51+
}
52+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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+
namespace Symfony\Component\Form\Extension\Core\Type;
13+
14+
use Symfony\Component\Form\Flow\AbstractFlowType;
15+
use Symfony\Component\Form\Flow\DataAccessor\PropertyPathStepAccessor;
16+
use Symfony\Component\Form\Flow\DataAccessor\StepAccessorInterface;
17+
use Symfony\Component\Form\Flow\DataStorage\DataStorageInterface;
18+
use Symfony\Component\Form\Flow\DataStorage\NullDataStorage;
19+
use Symfony\Component\Form\Flow\FormFlowBuilderInterface;
20+
use Symfony\Component\Form\Flow\FormFlowInterface;
21+
use Symfony\Component\Form\Flow\FormFlowStepView;
22+
use Symfony\Component\Form\FormBuilderInterface;
23+
use Symfony\Component\Form\FormInterface;
24+
use Symfony\Component\Form\FormView;
25+
use Symfony\Component\OptionsResolver\Exception\MissingOptionsException;
26+
use Symfony\Component\OptionsResolver\Options;
27+
use Symfony\Component\OptionsResolver\OptionsResolver;
28+
use Symfony\Component\PropertyAccess\PropertyAccess;
29+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
30+
use Symfony\Component\PropertyAccess\PropertyPath;
31+
use Symfony\Component\PropertyAccess\PropertyPathInterface;
32+
33+
/**
34+
* A multistep form.
35+
*
36+
* @author Yonel Ceruto <open@yceruto.dev>
37+
*/
38+
final class FormFlowType extends AbstractFlowType
39+
{
40+
public function __construct(
41+
private ?PropertyAccessorInterface $propertyAccessor = null,
42+
) {
43+
$this->propertyAccessor ??= PropertyAccess::createPropertyAccessor();
44+
}
45+
46+
/**
47+
* @param FormFlowBuilderInterface $builder
48+
*/
49+
public function buildForm(FormBuilderInterface $builder, array $options): void
50+
{
51+
$builder->setDataStorage($options['data_storage']);
52+
$builder->setStepAccessor($options['step_accessor']);
53+
}
54+
55+
/**
56+
* @param FormFlowInterface $form
57+
*/
58+
public function buildView(FormView $view, FormInterface $form, array $options): void
59+
{
60+
$view->vars['cursor'] = $form->getCursor();
61+
62+
foreach ($form->getConfig()->getSteps() as $name => $step) {
63+
$isCurrentStep = $name === $view->vars['cursor']->getCurrentStep();
64+
$view->vars['steps'][$name] = new FormFlowStepView($name, $isCurrentStep, $step->isSkipped($form->getViewData()));
65+
}
66+
}
67+
68+
public function configureOptions(OptionsResolver $resolver): void
69+
{
70+
$resolver->define('data_storage')
71+
->default(new NullDataStorage())
72+
->allowedTypes(DataStorageInterface::class);
73+
74+
$resolver->define('step_accessor')
75+
->default(function (Options $options) {
76+
if (!isset($options['step_property_path'])) {
77+
throw new MissingOptionsException('Option "step_property_path" is required.');
78+
}
79+
80+
return new PropertyPathStepAccessor($this->propertyAccessor, $options['step_property_path']);
81+
})
82+
->allowedTypes(StepAccessorInterface::class);
83+
84+
$resolver->define('step_property_path')
85+
->info('Required if the default step_accessor is being used')
86+
->allowedTypes('string', PropertyPathInterface::class)
87+
->normalize(function (Options $options, string|PropertyPathInterface $value): PropertyPathInterface {
88+
return \is_string($value) ? new PropertyPath($value) : $value;
89+
});
90+
91+
$resolver->define('auto_reset')
92+
->info('Whether the FormFlow will be reset automatically when it is finished')
93+
->default(true)
94+
->allowedTypes('bool');
95+
96+
$resolver->setDefault('validation_groups', function (FormFlowInterface $flow) {
97+
return ['Default', $flow->getCursor()->getCurrentStep()];
98+
});
99+
}
100+
101+
public function getParent(): string
102+
{
103+
return FormType::class;
104+
}
105+
}

src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ protected function loadTypeExtensions(): array
2424
{
2525
return [
2626
new Type\FormTypeHttpFoundationExtension(),
27+
new Type\FormFlowTypeSessionDataStorageExtension(),
2728
];
2829
}
2930
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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+
namespace Symfony\Component\Form\Extension\HttpFoundation\Type;
13+
14+
use Symfony\Component\Form\AbstractTypeExtension;
15+
use Symfony\Component\Form\Extension\Core\Type\FormFlowType;
16+
use Symfony\Component\Form\Flow\DataStorage\NullDataStorage;
17+
use Symfony\Component\Form\Flow\DataStorage\SessionDataStorage;
18+
use Symfony\Component\Form\Flow\FormFlowBuilderInterface;
19+
use Symfony\Component\Form\FormBuilderInterface;
20+
use Symfony\Component\HttpFoundation\RequestStack;
21+
22+
class FormFlowTypeSessionDataStorageExtension extends AbstractTypeExtension
23+
{
24+
public function __construct(
25+
private readonly ?RequestStack $requestStack = null,
26+
) {
27+
}
28+
29+
/**
30+
* @param FormFlowBuilderInterface $builder
31+
*/
32+
public function buildForm(FormBuilderInterface $builder, array $options): void
33+
{
34+
if (null === $this->requestStack || !$builder->getDataStorage() instanceof NullDataStorage) {
35+
return;
36+
}
37+
38+
$key = \sprintf('_sf_formflow.%s_%s', strtolower(str_replace('\\', '_', $builder->getType()->getInnerType()::class)), $builder->getName());
39+
$builder->setDataStorage(new SessionDataStorage($key, $this->requestStack));
40+
}
41+
42+
public static function getExtendedTypes(): iterable
43+
{
44+
yield FormFlowType::class;
45+
}
46+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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+
namespace Symfony\Component\Form\Flow;
13+
14+
use Symfony\Component\Form\AbstractType;
15+
use Symfony\Component\Form\Extension\Core\Type\FormFlowType;
16+
17+
/**
18+
* @author Yonel Ceruto <open@yceruto.dev>
19+
*/
20+
abstract class AbstractFlowType extends AbstractType implements FormFlowTypeInterface
21+
{
22+
public function getParent(): string
23+
{
24+
return FormFlowType::class;
25+
}
26+
}

0 commit comments

Comments
 (0)