Skip to content

Commit 7996e61

Browse files
committed
Add FormFlow component
1 parent c10a83f commit 7996e61

Some content is hidden

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

42 files changed

+2805
-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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ protected function loadTypes(): array
7878
new Type\TelType(),
7979
new Type\ColorType($this->translator),
8080
new Type\WeekType(),
81+
new Type\FormFlowActionType(),
82+
new Type\FormFlowNavigatorType(),
83+
new Type\FormFlowType($this->propertyAccessor),
8184
];
8285
}
8386

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

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+
}

0 commit comments

Comments
 (0)