From 48cd3da9587fae1c6b33826ed2852015fa5dfe07 Mon Sep 17 00:00:00 2001 From: Nicolas Joubert Date: Mon, 5 Oct 2026 10:16:29 +0200 Subject: [PATCH] fix #89 Resolve the ui.default option with a normalizer instead of nested options removed in Symfony 8 Nested options defined with setDefault() are deprecated since symfony/options-resolver 7.3 and removed in 8.0: the closure itself was returned, so the launch form of a process without ui.default failed (Cannot use object of type Closure as array) and ui.default was no longer validated. setOptions() does not exist before 7.3: a normalizer works with Symfony 6.4, 7.4 and 8. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 + src/Manager/ProcessConfigurationsManager.php | 24 +++++--- tests/App/config/packages.yaml | 5 -- tests/Functional/LaunchActionTest.php | 2 + .../ProcessConfigurationsManagerTest.php | 56 ++++++++++++++++++- 5 files changed, 75 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a46040..ae47a73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Latest * [#84](https://github.com/cleverage/ui-process-bundle/issues/84) Add `LogRecord::hasContextInfo()`, deprecate the misnamed `LogRecord::contextIsEmpty()` (it returns `true` when the context is not empty). Add tests. * [#85](https://github.com/cleverage/ui-process-bundle/issues/85) `ProcessHandler`: default report increment level aligned on the bundle configuration (`Warning`); declare the `symfony/ux-twig-component` dependency. Add tests. +## Fixes +* [#89](https://github.com/cleverage/ui-process-bundle/issues/89) `ProcessConfigurationsManager`: resolve the `ui.default` option with a normalizer instead of nested options defined with `setDefault()` (deprecated since symfony/options-resolver 7.3, removed in 8.0). With Symfony 8, a process launched with the UI form (`ui_launch_mode: form`) without `ui.default` no longer fails (`Cannot use object of type Closure as array`), and `ui.default` is validated again. Add tests. + v3.0.2 ------ diff --git a/src/Manager/ProcessConfigurationsManager.php b/src/Manager/ProcessConfigurationsManager.php index eb365fc..913e738 100644 --- a/src/Manager/ProcessConfigurationsManager.php +++ b/src/Manager/ProcessConfigurationsManager.php @@ -83,16 +83,26 @@ private function resolveUiOptions(array $options): array 'ui_launch_mode' => 'modal', 'constraints' => [], 'run' => null, - 'default' => static function (OptionsResolver $defaultResolver) { - $defaultResolver->setDefault('input', null); - $defaultResolver->setDefault('context', static function (OptionsResolver $contextResolver) { - $contextResolver->setPrototype(true); - $contextResolver->setRequired(['key', 'value']); - }); - }, + 'default' => [], ] ); $uiResolver->setAllowedValues('entrypoint_type', ['text', 'file']); + // Resolved by a normalizer rather than as nested options: nested options defined with setDefault() are + // deprecated since symfony/options-resolver 7.3 and removed in 8.0, setOptions() does not exist before 7.3 + $uiResolver->setAllowedTypes('default', 'array'); + $uiResolver->setNormalizer('default', static function (Options $options, array $default): array { + $defaultResolver = new OptionsResolver(); + $defaultResolver->setDefaults(['input' => null, 'context' => []]); + $defaultResolver->setAllowedTypes('context', 'array[]'); + $defaultResolver->setNormalizer('context', static function (Options $options, array $context): array { + $contextResolver = new OptionsResolver(); + $contextResolver->setRequired(['key', 'value']); + + return array_map($contextResolver->resolve(...), $context); + }); + + return $defaultResolver->resolve($default); + }); $uiResolver->setNormalizer('constraints', static fn (Options $options, array $values): array => (new ConstraintLoader())->buildConstraints($values)); $uiResolver->setAllowedValues('ui_launch_mode', ['modal', null, 'form']); diff --git a/tests/App/config/packages.yaml b/tests/App/config/packages.yaml index f76c0f7..c285860 100644 --- a/tests/App/config/packages.yaml +++ b/tests/App/config/packages.yaml @@ -73,11 +73,6 @@ clever_age_process: ui: ui_launch_mode: form entrypoint_type: file - # Required with Symfony 8: without it, ProcessConfigurationsManager returns the `default` nested - # options closure (nested options defined with setDefault() are no longer supported) - default: - input: 'not used by a file input' - context: [] tasks: data: service: '@CleverAge\ProcessBundle\Task\DummyTask' diff --git a/tests/Functional/LaunchActionTest.php b/tests/Functional/LaunchActionTest.php index 50c88bd..92c610b 100644 --- a/tests/Functional/LaunchActionTest.php +++ b/tests/Functional/LaunchActionTest.php @@ -121,8 +121,10 @@ public function testLaunchWithFileUpload(): void $file = tempnam(sys_get_temp_dir(), 'upload'); file_put_contents((string) $file, 'line1'); + // test.upload has no "default" UI option: the form must be displayed with empty default values $crawler = $this->client->request('GET', '/process?routeName=process_launch&process=test.upload'); self::assertResponseIsSuccessful(); + self::assertCount(0, $crawler->filter('input[name^="launch[context]"]')); self::assertCount(1, $crawler->filter('input[type="file"][name="launch[input]"]')); $form = $crawler->selectButton('Launch')->form(); diff --git a/tests/Manager/ProcessConfigurationsManagerTest.php b/tests/Manager/ProcessConfigurationsManagerTest.php index f0f9b8a..6b7feb3 100644 --- a/tests/Manager/ProcessConfigurationsManagerTest.php +++ b/tests/Manager/ProcessConfigurationsManagerTest.php @@ -17,8 +17,12 @@ use CleverAge\ProcessBundle\Registry\ProcessConfigurationRegistry; use CleverAge\UiProcessBundle\Manager\ProcessConfigurationsManager; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Symfony\Component\OptionsResolver\Exception\ExceptionInterface as OptionsResolverException; use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; +use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; +use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException; use Symfony\Component\Validator\Constraints\Length; use Symfony\Component\Validator\Constraints\NotBlank; @@ -59,9 +63,7 @@ public function testDefaultUiOptions(): void /** @var mixed $run the PHPDoc type of the option is wrong */ $run = $options['run']; self::assertNull($run); - // The "default" nested option is not asserted: defined with setDefault() and a closure, it is not resolved as - // a nested option with symfony/options-resolver 8 (the closure itself is returned) - self::assertArrayHasKey('default', $options); + self::assertSame(['input' => null, 'context' => []], $options['default']); } public function testConfiguredUiOptions(): void @@ -100,6 +102,54 @@ public function testConfiguredUiOptions(): void self::assertSame(10, $options['constraints'][1]->max); } + public function testDefaultWithoutContext(): void + { + $manager = $this->createManager([ + 'test.process' => $this->rawProcess(['ui' => ['default' => ['input' => 'data.csv']]]), + ]); + + $options = $manager->getUiOptions('test.process'); + + self::assertNotNull($options); + self::assertSame(['input' => 'data.csv', 'context' => []], $options['default']); + } + + /** + * @param array $default + * @param class-string $exception + */ + #[DataProvider('provideInvalidDefault')] + public function testInvalidDefault(array $default, string $exception): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['default' => $default]])]); + + $this->expectException($exception); + $manager->getUiOptions('test.process'); + } + + /** + * @return iterable, class-string}> + */ + public static function provideInvalidDefault(): iterable + { + yield 'unknown option' => [['inputs' => 'data.csv'], UndefinedOptionsException::class]; + yield 'context is not an array' => [['context' => 'foo'], InvalidOptionsException::class]; + yield 'context item is not an array' => [['context' => ['foo']], InvalidOptionsException::class]; + yield 'context item without value' => [['context' => [['key' => 'foo']]], MissingOptionsException::class]; + yield 'context item with an unknown option' => [ + ['context' => [['key' => 'foo', 'value' => 'bar', 'type' => 'string']]], + UndefinedOptionsException::class, + ]; + } + + public function testDefaultIsNotAnArray(): void + { + $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['default' => 'data.csv']])]); + + $this->expectException(InvalidOptionsException::class); + $manager->getUiOptions('test.process'); + } + public function testInvalidEntrypointType(): void { $manager = $this->createManager(['test.process' => $this->rawProcess(['ui' => ['entrypoint_type' => 'csv']])]);