diff --git a/CHANGELOG.md b/CHANGELOG.md index 66d7da15..0e64b9fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 8.1 --- + * Add support for submitting forms with unchecked checkboxes in request handlers * Add `ResetFlowType` button in `NavigatorFlowType` that you can display with `with_reset` option * Allow injecting a `ViolationMapperInterface` into `FormTypeValidatorExtension` * Deprecate passing boolean as the second argument of `ValidatorExtension` and `FormTypeValidatorExtension`'s constructors; pass a `ViolationMapperInterface` instead diff --git a/Extension/HttpFoundation/HttpFoundationRequestHandler.php b/Extension/HttpFoundation/HttpFoundationRequestHandler.php index d7875e7b..a30c87de 100644 --- a/Extension/HttpFoundation/HttpFoundationRequestHandler.php +++ b/Extension/HttpFoundation/HttpFoundationRequestHandler.php @@ -14,6 +14,7 @@ namespace Symfony\Component\Form\Extension\HttpFoundation; use Symfony\Component\Form\Exception\UnexpectedTypeException; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\MissingDataHandler; use Symfony\Component\Form\RequestHandlerInterface; use Symfony\Component\Form\Util\FormUtil; use Symfony\Component\Form\Util\ServerParams; @@ -30,10 +31,12 @@ use Symfony\Component\HttpFoundation\Request; class HttpFoundationRequestHandler implements RequestHandlerInterface { private ServerParams $serverParams; + private MissingDataHandler $missingDataHandler; public function __construct(?ServerParams $serverParams = null) { $this->serverParams = $serverParams ?? new ServerParams(); + $this->missingDataHandler = new MissingDataHandler(); } public function handleRequest(FormInterface $form, mixed $request = null): void @@ -44,6 +47,7 @@ class HttpFoundationRequestHandler implements RequestHandlerInterface $name = $form->getName(); $method = $form->getConfig()->getMethod(); + $missingData = $this->missingDataHandler->missingData; if ($method !== $request->getMethod()) { return; @@ -55,13 +59,15 @@ class HttpFoundationRequestHandler implements RequestHandlerInterface if ('' === $name) { $data = $request->query->all(); } else { - // Don't submit GET requests if the form's name does not exist - // in the request - if (!$request->query->has($name)) { + $queryData = $request->query->all()[$name] ?? $missingData; + + $data = $this->missingDataHandler->handle($form, $queryData); + + if ($missingData === $data) { + // Don't submit GET requests if the form's name does not exist + // in the request return; } - - $data = $request->query->all()[$name]; } } else { // Mark the form with an error if the uploaded size was too large @@ -88,6 +94,15 @@ class HttpFoundationRequestHandler implements RequestHandlerInterface $params = $request->request->all()[$name] ?? $default; $files = $request->files->get($name, $default); } else { + $params = $missingData; + $files = null; + } + + if ('PATCH' !== $method) { + $params = $this->missingDataHandler->handle($form, $params); + } + + if ($missingData === $params) { // Don't submit the form if it is not present in the request return; } diff --git a/MissingDataHandler.php b/MissingDataHandler.php new file mode 100644 index 00000000..022bdc35 --- /dev/null +++ b/MissingDataHandler.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Form; + +/** + * @internal + */ +class MissingDataHandler +{ + public readonly \stdClass $missingData; + + public function __construct() + { + $this->missingData = new \stdClass(); + } + + public function handle(FormInterface $form, mixed $data): mixed + { + $processedData = $this->handleMissingData($form, $data); + + return $processedData === $this->missingData ? $data : $processedData; + } + + private function handleMissingData(FormInterface $form, mixed $data): mixed + { + $config = $form->getConfig(); + $missingData = $this->missingData; + $falseValues = $config->getOption('false_values', null); + + if (\is_array($falseValues)) { + if ($data === $missingData) { + return $falseValues[0] ?? null; + } + + if (\in_array($data, $falseValues)) { + return $data; + } + } + + if (null === $data || $missingData === $data) { + $data = $config->getCompound() ? [] : $data; + } + + if (\is_array($data)) { + $children = $config->getCompound() ? $form->all() : [$form]; + + foreach ($children as $child) { + $name = $child->getName(); + $childData = $missingData; + + if (\array_key_exists($name, $data)) { + $childData = $data[$name]; + } + + $value = $this->handleMissingData($child, $childData); + + if ($missingData !== $value) { + $data[$name] = $value; + } + } + + return $data ?: $missingData; + } + + return $data; + } +} diff --git a/NativeRequestHandler.php b/NativeRequestHandler.php index bee54fa6..498bc625 100644 --- a/NativeRequestHandler.php +++ b/NativeRequestHandler.php @@ -23,6 +23,7 @@ use Symfony\Component\Form\Util\ServerParams; class NativeRequestHandler implements RequestHandlerInterface { private ServerParams $serverParams; + private MissingDataHandler $missingDataHandler; /** * The allowed keys of the $_FILES array. @@ -39,6 +40,7 @@ class NativeRequestHandler implements RequestHandlerInterface public function __construct(?ServerParams $params = null) { $this->serverParams = $params ?? new ServerParams(); + $this->missingDataHandler = new MissingDataHandler(); } /** @@ -52,6 +54,7 @@ class NativeRequestHandler implements RequestHandlerInterface $name = $form->getName(); $method = $form->getConfig()->getMethod(); + $missingData = $this->missingDataHandler->missingData; if ($method !== self::getRequestMethod()) { return; @@ -63,13 +66,14 @@ class NativeRequestHandler implements RequestHandlerInterface if ('' === $name) { $data = $_GET; } else { - // Don't submit GET requests if the form's name does not exist - // in the request - if (!isset($_GET[$name])) { + $queryData = $_GET[$name] ?? $missingData; + $data = $this->missingDataHandler->handle($form, $queryData); + + if ($missingData === $data) { + // Don't submit GET requests if the form's name does not exist + // in the request return; } - - $data = $_GET[$name]; } } else { // Mark the form with an error if the uploaded size was too large @@ -101,6 +105,15 @@ class NativeRequestHandler implements RequestHandlerInterface $params = \array_key_exists($name, $_POST) ? $_POST[$name] : $default; $files = \array_key_exists($name, $fixedFiles) ? $fixedFiles[$name] : $default; } else { + $params = $missingData; + $files = null; + } + + if ('PATCH' !== $method) { + $params = $this->missingDataHandler->handle($form, $params); + } + + if ($missingData === $params) { // Don't submit the form if it is not present in the request return; } diff --git a/Tests/AbstractRequestHandlerTestCase.php b/Tests/AbstractRequestHandlerTestCase.php index 68dfafab..ac4e2123 100644 --- a/Tests/AbstractRequestHandlerTestCase.php +++ b/Tests/AbstractRequestHandlerTestCase.php @@ -15,7 +15,9 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\Form\Extension\Core\DataMapper\DataMapper; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\FormType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Form; use Symfony\Component\Form\FormBuilder; @@ -60,6 +62,84 @@ abstract class AbstractRequestHandlerTestCase extends TestCase $this->request = null; } + #[DataProvider('methodExceptPatchProvider')] + public function testSubmitCheckboxInCollectionFormWithEmptyData($method) + { + $form = $this->factory->create(CollectionType::class, [true, false, true], [ + 'entry_type' => CheckboxType::class, + 'method' => $method, + ]); + + $this->setRequestData($method, []); + + $this->requestHandler->handleRequest($form, $this->request); + + $this->assertEqualsCanonicalizing([false, false, false], $form->getData()); + } + + #[DataProvider('methodExceptPatchProvider')] + public function testSubmitCheckboxInCollectionFormWithPartialData($method) + { + $form = $this->factory->create(CollectionType::class, [true, false, true], [ + 'entry_type' => CheckboxType::class, + 'method' => $method, + ]); + + $this->setRequestData($method, [ + 'collection' => [ + 1 => true, + ], + ]); + + $this->requestHandler->handleRequest($form, $this->request); + + $this->assertEqualsCanonicalizing([false, true, false], $form->getData()); + } + + #[DataProvider('methodExceptPatchProvider')] + public function testSubmitCheckboxFormWithEmptyData($method) + { + $form = $this->factory->create(FormType::class, ['subform' => ['checkbox' => true]], [ + 'method' => $method, + ]) + ->add('subform', FormType::class, [ + 'compound' => true, + ]); + + $form->get('subform') + ->add('checkbox', CheckboxType::class); + + $this->setRequestData($method, []); + + $this->requestHandler->handleRequest($form, $this->request); + + $this->assertEquals(['subform' => ['checkbox' => false]], $form->getData()); + } + + #[DataProvider('methodExceptPatchProvider')] + public function testSubmitSimpleCheckboxFormWithEmptyData($method) + { + $form = $this->factory->createNamed('checkbox', CheckboxType::class, true, [ + 'method' => $method, + ]); + + $this->setRequestData($method, []); + + $this->requestHandler->handleRequest($form, $this->request); + + $this->assertFalse($form->getData()); + } + + public static function methodExceptPatchProvider(): array + { + return [ + ['POST'], + ['PUT'], + ['DELETE'], + ['GET'], + ]; + } + public static function methodExceptGetProvider(): array { return [