Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 689f674

Browse filesBrowse files
committed
Add FormFlow component
1 parent 7c43418 commit 689f674
Copy full SHA for 689f674

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Dismiss banner
Expand file treeCollapse file tree

42 files changed

+2933
-2
lines changed

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

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/Controller/AbstractController.php
+6Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/Resources/config/form.php
+5Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/composer.json
+2-2Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Component/Form/CHANGELOG.md
+5Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Component/Form/Extension/Core/CoreExtension.php
+3Lines 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

+93Lines 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+
}
+48Lines 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+
}
+123Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Component/Form/Extension/HttpFoundation/HttpFoundationExtension.php
+1Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.