diff --git a/src/Bundle/Builder/Field/CallableField.php b/src/Bundle/Builder/Field/CallableField.php index bfbff649..e24839d7 100644 --- a/src/Bundle/Builder/Field/CallableField.php +++ b/src/Bundle/Builder/Field/CallableField.php @@ -22,4 +22,18 @@ public static function create(string $name, callable $callable, bool $htmlspecia ->setOption('htmlspecialchars', $htmlspecialchars) ; } + + public static function createForService(string $name, string $service, ?string $method = null, bool $htmlspecialchars = true): FieldInterface + { + $field = Field::create($name, 'callable') + ->setOption('service', $service) + ->setOption('htmlspecialchars', $htmlspecialchars) + ; + + if ($method !== null) { + $field->setOption('method', $method); + } + + return $field; + } } diff --git a/src/Bundle/DependencyInjection/SyliusGridExtension.php b/src/Bundle/DependencyInjection/SyliusGridExtension.php index 76d1470f..2eae760f 100644 --- a/src/Bundle/DependencyInjection/SyliusGridExtension.php +++ b/src/Bundle/DependencyInjection/SyliusGridExtension.php @@ -16,6 +16,7 @@ use Sylius\Bundle\CurrencyBundle\SyliusCurrencyBundle; use Sylius\Bundle\GridBundle\Grid\GridInterface; use Sylius\Bundle\GridBundle\SyliusGridBundle; +use Sylius\Component\Grid\Annotation\AsGridFieldCallableService; use Sylius\Component\Grid\Attribute\AsFilter; use Sylius\Component\Grid\Data\DataProviderInterface; use Sylius\Component\Grid\Filtering\ConfigurableFilterInterface; @@ -85,6 +86,13 @@ static function (ChildDefinition $definition, AsFilter $attribute, \Reflector $r $container->registerForAutoconfiguration(DataProviderInterface::class) ->addTag('sylius.grid_data_provider') ; + + $container->registerAttributeForAutoconfiguration( + AsGridFieldCallableService::class, + static function (ChildDefinition $definition, AsGridFieldCallableService $attribute, \Reflector $reflector): void { + $definition->addTag('sylius.grid_field_callable_service'); + }, + ); } public function getConfiguration(array $config, ContainerBuilder $container): Configuration diff --git a/src/Bundle/Resources/config/services/field_types.xml b/src/Bundle/Resources/config/services/field_types.xml index 5b535898..38c5bec2 100644 --- a/src/Bundle/Resources/config/services/field_types.xml +++ b/src/Bundle/Resources/config/services/field_types.xml @@ -17,6 +17,7 @@ + diff --git a/src/Bundle/Tests/Functional/GridUiTest.php b/src/Bundle/Tests/Functional/GridUiTest.php index c6e6e9d3..6a5373d5 100644 --- a/src/Bundle/Tests/Functional/GridUiTest.php +++ b/src/Bundle/Tests/Functional/GridUiTest.php @@ -55,6 +55,19 @@ public function it_shows_authors_ids(): void ); } + /** @test */ + public function it_shows_authors_nationalities(): void + { + $this->client->request('GET', '/authors/?limit=100'); + + $nationalities = $this->getAuthorNationalitiesFromResponse(); + + $this->assertCount(3, array_unique($nationalities)); + $this->assertContains('US', $nationalities); + $this->assertContains('EN', $nationalities); + $this->assertContains('', $nationalities); + } + /** @test */ public function it_sorts_authors_by_name_ascending_by_default(): void { @@ -313,6 +326,16 @@ private function getAuthorIdsFromResponse(): array ); } + /** @return string[] */ + private function getAuthorNationalitiesFromResponse(): array + { + return $this->getCrawler() + ->filter('[data-test-nationality]') + ->each( + fn (Crawler $node): string => $node->text(), + ); + } + /** @return string[] */ private function getAuthorNamesFromResponse(): array { diff --git a/src/Component/Annotation/AsGridFieldCallableService.php b/src/Component/Annotation/AsGridFieldCallableService.php new file mode 100644 index 00000000..9a086520 --- /dev/null +++ b/src/Component/Annotation/AsGridFieldCallableService.php @@ -0,0 +1,19 @@ +dataExtractor->get($field, $data); - $value = call_user_func($options['callable'], $value); + $value = call_user_func($this->getCallable($options), $value); try { $value = (string) $value; @@ -46,11 +53,45 @@ public function render(Field $field, $data, array $options): string return $value; } + private function getCallable(array $options): callable + { + if (isset($options['callable'])) { + return $options['callable']; + } + + if (!$this->locator->has($options['service'])) { + throw new \RuntimeException(sprintf('Service "%s" not found, make sure it is tagged with "sylius.grid_field_callable_service".', $options['service'])); + } + + $service = $this->locator->get($options['service']); + if (isset($options['method'])) { + $callable = [$service, $options['method']]; + + if (!is_callable($callable)) { + throw new \RuntimeException(sprintf('The method "%s" is not callable on service "%s".', $options['method'], $options['service'])); + } + + return $callable; + } + + if (!is_callable($service)) { + throw new \RuntimeException(sprintf('The service "%s" is not callable.', $options['service'])); + } + + return $service; + } + public function configureOptions(OptionsResolver $resolver): void { - $resolver->setRequired('callable'); + $resolver->setDefined('callable'); $resolver->setAllowedTypes('callable', 'callable'); + $resolver->setDefined('service'); + $resolver->setAllowedTypes('service', 'string'); + + $resolver->setDefined('method'); + $resolver->setAllowedTypes('method', 'string'); + $resolver->setDefault('htmlspecialchars', true); $resolver->setAllowedTypes('htmlspecialchars', 'bool'); } diff --git a/src/Component/spec/FieldTypes/CallableFieldTypeSpec.php b/src/Component/spec/FieldTypes/CallableFieldTypeSpec.php index 0123d21d..a20e47d1 100644 --- a/src/Component/spec/FieldTypes/CallableFieldTypeSpec.php +++ b/src/Component/spec/FieldTypes/CallableFieldTypeSpec.php @@ -18,12 +18,30 @@ use Sylius\Component\Grid\Definition\Field; use Sylius\Component\Grid\Exception\UnexpectedValueException; use Sylius\Component\Grid\FieldTypes\FieldTypeInterface; +use Symfony\Contracts\Service\ServiceLocatorTrait; +use Symfony\Contracts\Service\ServiceProviderInterface; final class CallableFieldTypeSpec extends ObjectBehavior { function let(DataExtractorInterface $dataExtractor): void { - $this->beConstructedWith($dataExtractor); + $this->beConstructedWith( + $dataExtractor, + new class(['my_service' => fn () => new class() { + public function __invoke(string $value): string + { + return strtoupper($value); + } + + public function concatenate(array $value): string + { + return implode(', ', $value); + } + }, + ]) implements ServiceProviderInterface { + use ServiceLocatorTrait; + }, + ); } function it_is_a_grid_field_type(): void @@ -79,6 +97,31 @@ function it_uses_data_extractor_to_obtain_data_and_passes_it_to_a_static_callabl ])->shouldReturn('bar'); } + function it_uses_data_extractor_to_obtain_data_and_passes_it_to_a_service( + DataExtractorInterface $dataExtractor, + Field $field, + ): void { + $dataExtractor->get($field, ['foo' => 'bar'])->willReturn('bar'); + + $this->render($field, ['foo' => 'bar'], [ + 'service' => 'my_service', + 'htmlspecialchars' => true, + ])->shouldReturn('BAR'); + } + + function it_uses_data_extractor_to_obtain_data_and_passes_it_to_a_service_and_method( + DataExtractorInterface $dataExtractor, + Field $field, + ): void { + $dataExtractor->get($field, ['foo' => ['foo', 'bar', 'foobar']])->willReturn(['foo', 'bar', 'foobar']); + + $this->render($field, ['foo' => ['foo', 'bar', 'foobar']], [ + 'service' => 'my_service', + 'method' => 'concatenate', + 'htmlspecialchars' => true, + ])->shouldReturn('foo, bar, foobar'); + } + function it_throws_an_exception_when_a_callable_return_value_cannot_be_casted_to_string( DataExtractorInterface $dataExtractor, Field $field, @@ -98,6 +141,35 @@ function it_throws_an_exception_when_a_callable_return_value_cannot_be_casted_to ]); } + function it_throws_an_exception_when_neither_callable_nor_service_options_are_defined( + DataExtractorInterface $dataExtractor, + Field $field, + ): void { + $this + ->shouldThrow(\RuntimeException::class) + ->during('render', [ + $field, + ['foo' => 'bar'], + [], + ]); + } + + function it_throws_an_exception_when_both_callable_and_service_options_are_defined( + DataExtractorInterface $dataExtractor, + Field $field, + ): void { + $this + ->shouldThrow(\RuntimeException::class) + ->during('render', [ + $field, + ['foo' => 'bar'], + [ + 'callable' => fn () => new \stdclass(), + 'service' => 'my_service', + ], + ]); + } + static function callable(mixed $value): string { return strtolower($value); diff --git a/tests/Application/config/services.yaml b/tests/Application/config/services.yaml index 99d572df..4c410a97 100644 --- a/tests/Application/config/services.yaml +++ b/tests/Application/config/services.yaml @@ -30,3 +30,5 @@ services: App\BoardGameBlog\: resource: '../src/BoardGameBlog' + + App\Helper\GridHelper: ~ diff --git a/tests/Application/config/sylius/grids.yaml b/tests/Application/config/sylius/grids.yaml index 60d29d59..6670740e 100644 --- a/tests/Application/config/sylius/grids.yaml +++ b/tests/Application/config/sylius/grids.yaml @@ -79,11 +79,14 @@ sylius_grid: label: Name sortable: ~ nationality: - type: string + type: callable + options: + service: 'App\Helper\GridHelper' + method: 'formatNationality' label: Name sortable: nationality.name path: nationality - limits: [10, 5, 15] + limits: [10, 5, 15, 100] app_book_by_american_authors: driver: diff --git a/tests/Application/config/sylius/grids/author.php b/tests/Application/config/sylius/grids/author.php index e75264f7..b3f8b8a5 100644 --- a/tests/Application/config/sylius/grids/author.php +++ b/tests/Application/config/sylius/grids/author.php @@ -32,10 +32,10 @@ ->setSortable(true), ) ->addField( - StringField::create('nationality') + CallableField::createForService('nationality', \App\Helper\GridHelper::class) ->setLabel('Nationality') ->setSortable(true, 'nationality.name'), ) - ->setLimits([10, 5, 15]), + ->setLimits([10, 5, 15, 100]), ); }; diff --git a/tests/Application/src/Grid/AuthorGrid.php b/tests/Application/src/Grid/AuthorGrid.php index f779f75b..52588b11 100644 --- a/tests/Application/src/Grid/AuthorGrid.php +++ b/tests/Application/src/Grid/AuthorGrid.php @@ -55,12 +55,12 @@ public function buildGrid(GridBuilderInterface $gridBuilder): void ->setSortable(true), ) ->addField( - StringField::create('nationality') + CallableField::createForService('nationality', \App\Helper\GridHelper::class, 'formatNationality') ->setLabel('Name') ->setPath('nationality') ->setSortable(true, 'nationality.name'), ) - ->setLimits([10, 5, 15]) + ->setLimits([10, 5, 15, 100]) ; } } diff --git a/tests/Application/src/Helper/GridHelper.php b/tests/Application/src/Helper/GridHelper.php index b4761edf..fcc35609 100644 --- a/tests/Application/src/Helper/GridHelper.php +++ b/tests/Application/src/Helper/GridHelper.php @@ -13,8 +13,25 @@ namespace App\Helper; +use Sylius\Component\Grid\Annotation\AsGridFieldCallableService; + +#[AsGridFieldCallableService] final class GridHelper { + public function __invoke(?string $value): string + { + return $this->formatNationality($value); + } + + public function formatNationality(?string $value): string + { + return match ($value) { + 'English' => 'EN', + 'American' => 'US', + null => '', + }; + } + public static function addHashPrefix(string $value): string { return '#' . $value;