Skip to content

Commit bb260f8

Browse files
committed
Add FormFlow for multistep forms management
1 parent 3fe77f0 commit bb260f8

Some content is hidden

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

45 files changed

+3332
-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|^8.0",
4646
"symfony/dotenv": "^6.4|^7.0|^8.0",
4747
"symfony/polyfill-intl-icu": "~1.0",
48-
"symfony/form": "^6.4|^7.0|^8.0",
48+
"symfony/form": "^7.4|^8.0",
4949
"symfony/expression-language": "^6.4|^7.0|^8.0",
5050
"symfony/html-sanitizer": "^6.4|^7.0|^8.0",
5151
"symfony/http-client": "^6.4|^7.0|^8.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.4",
9292
"symfony/lock": "<6.4",
9393
"symfony/mailer": "<6.4",
9494
"symfony/messenger": "<6.4",

src/Symfony/Component/Form/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ CHANGELOG
55
---
66

77
* Add `input=date_point` to `DateTimeType`, `DateType` and `TimeType`
8+
* Add `FormFlow` for multistep forms management
89

910
7.3
1011
---

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

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: 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 <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\SessionDataStorage;
17+
use Symfony\Component\Form\Flow\FormFlowBuilderInterface;
18+
use Symfony\Component\Form\FormBuilderInterface;
19+
use Symfony\Component\HttpFoundation\RequestStack;
20+
21+
class FormFlowTypeSessionDataStorageExtension extends AbstractTypeExtension
22+
{
23+
public function __construct(
24+
private readonly ?RequestStack $requestStack = null,
25+
) {
26+
}
27+
28+
public function buildForm(FormBuilderInterface $builder, array $options): void
29+
{
30+
\assert($builder instanceof FormFlowBuilderInterface);
31+
32+
if (null === $this->requestStack || null !== $options['data_storage']) {
33+
return;
34+
}
35+
36+
$key = \sprintf('_sf_formflow.%s_%s', strtolower(str_replace('\\', '_', $builder->getType()->getInnerType()::class)), $builder->getName());
37+
$builder->setDataStorage(new SessionDataStorage($key, $this->requestStack));
38+
}
39+
40+
public static function getExtendedTypes(): iterable
41+
{
42+
return [FormFlowType::class];
43+
}
44+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy