Skip to content

Commit 689f674

Browse files
committed
Add FormFlow component
1 parent 7c43418 commit 689f674

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

+2933
-2
lines changed

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@
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;
26+
use Symfony\Component\Form\FormTypeInterface;
2327
use Symfony\Component\HttpFoundation\BinaryFileResponse;
2428
use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException;
2529
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -345,6 +349,8 @@ protected function createAccessDeniedException(string $message = 'Access Denied.
345349

346350
/**
347351
* Creates and returns a Form instance from the type of the form.
352+
*
353+
* @return ($type is class-string<FormFlowTypeInterface> ? FormFlowInterface : FormInterface)
348354
*/
349355
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
350356
{

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: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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'] ?? new NullDataStorage());
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'] = $cursor = $form->getCursor();
59+
60+
$index = 0;
61+
$position = 1;
62+
foreach ($form->getConfig()->getSteps() as $name => $step) {
63+
$isSkipped = $step->isSkipped($form->getViewData());
64+
65+
$stepVars = [
66+
'name' => $name,
67+
'index' => $index++,
68+
'position' => $isSkipped ? -1 : $position++,
69+
'is_current_step' => $name === $cursor->getCurrentStep(),
70+
'can_be_skipped' => null !== $step->getSkip(),
71+
'is_skipped' => $isSkipped,
72+
];
73+
74+
$view->vars['steps'][$name] = $stepVars;
75+
76+
if (!$isSkipped) {
77+
$view->vars['visible_steps'][$name] = $stepVars;
78+
}
79+
}
80+
}
81+
82+
public function configureOptions(OptionsResolver $resolver): void
83+
{
84+
$resolver->define('data_storage')
85+
->default(null)
86+
->allowedTypes('null', DataStorageInterface::class);
87+
88+
$resolver->define('step_accessor')
89+
->default(function (Options $options) {
90+
if (!isset($options['step_property_path'])) {
91+
throw new MissingOptionsException('Option "step_property_path" is required.');
92+
}
93+
94+
return new PropertyPathStepAccessor($this->propertyAccessor, $options['step_property_path']);
95+
})
96+
->allowedTypes(StepAccessorInterface::class);
97+
98+
$resolver->define('step_property_path')
99+
->info('Required if the default step_accessor is being used')
100+
->allowedTypes('string', PropertyPathInterface::class)
101+
->normalize(function (Options $options, string|PropertyPathInterface $value): PropertyPathInterface {
102+
return \is_string($value) ? new PropertyPath($value) : $value;
103+
});
104+
105+
$resolver->define('auto_reset')
106+
->info('Whether the FormFlow will be reset automatically when it is finished')
107+
->default(true)
108+
->allowedTypes('bool');
109+
110+
$resolver->setDefault('validation_groups', function (FormFlowInterface $flow) {
111+
return ['Default', $flow->getCursor()->getCurrentStep()];
112+
});
113+
114+
$resolver->setDefault('data', function (Options $options) {
115+
return $options['data_class'] ? new $options['data_class']() : [];
116+
});
117+
}
118+
119+
public function getParent(): string
120+
{
121+
return FormType::class;
122+
}
123+
}

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
}

0 commit comments

Comments
 (0)