diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 00000000..55506027 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,6 @@ +parameters: + ignoreErrors: + - + message: "#^Dead catch \\- Throwable is never thrown in the try block\\.$#" + count: 1 + path: src/Component/FieldTypes/CallableFieldType.php diff --git a/phpstan.neon b/phpstan.neon index 659daca6..1b942ec4 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,7 +1,7 @@ includes: + - phpstan-baseline.neon - vendor/phpstan/phpstan-webmozart-assert/extension.neon - vendor/phpstan/phpstan-phpunit/extension.neon - - vendor/phpstan/phpstan-phpunit/rules.neon parameters: diff --git a/src/Bundle/Builder/Field/CallableField.php b/src/Bundle/Builder/Field/CallableField.php new file mode 100644 index 00000000..bfbff649 --- /dev/null +++ b/src/Bundle/Builder/Field/CallableField.php @@ -0,0 +1,25 @@ +setOption('callable', $callable) + ->setOption('htmlspecialchars', $htmlspecialchars) + ; + } +} diff --git a/src/Bundle/Parser/OptionsParser.php b/src/Bundle/Parser/OptionsParser.php new file mode 100644 index 00000000..e9e8bc53 --- /dev/null +++ b/src/Bundle/Parser/OptionsParser.php @@ -0,0 +1,55 @@ +parseOptions($parameter); + } + + return $this->parseOption($parameter); + }, + $parameters, + ); + } + + private function parseOption(mixed $parameter): mixed + { + if (!is_string($parameter)) { + return $parameter; + } + + if (str_starts_with($parameter, 'callable:')) { + return $this->parseOptionCallable(substr($parameter, 9)); + } + + return $parameter; + } + + private function parseOptionCallable(string $callable): \Closure + { + if (!is_callable($callable)) { + throw new InvalidArgumentException(\sprintf('%s is not a callable.', $callable)); + } + + return $callable(...); + } +} diff --git a/src/Bundle/Parser/OptionsParserInterface.php b/src/Bundle/Parser/OptionsParserInterface.php new file mode 100644 index 00000000..2e988190 --- /dev/null +++ b/src/Bundle/Parser/OptionsParserInterface.php @@ -0,0 +1,19 @@ +twig = $twig; $this->fieldsRegistry = $fieldsRegistry; @@ -58,6 +62,17 @@ public function __construct( $this->defaultTemplate = $defaultTemplate; $this->actionTemplates = $actionTemplates; $this->filterTemplates = $filterTemplates; + $this->optionsParser = $optionsParser; + + if (null === $optionsParser) { + trigger_deprecation( + 'sylius/grid-bundle', + '1.14', + 'Not passing an instance of "%s" as the eighth constructor argument of "%s" is deprecated.', + OptionsParserInterface::class, + self::class, + ); + } } public function render(GridViewInterface $gridView, ?string $template = null) @@ -71,7 +86,12 @@ public function renderField(GridViewInterface $gridView, Field $field, $data) $fieldType = $this->fieldsRegistry->get($field->getType()); $resolver = new OptionsResolver(); $fieldType->configureOptions($resolver); - $options = $resolver->resolve($field->getOptions()); + + $options = $field->getOptions(); + if (null !== $this->optionsParser) { + $options = $this->optionsParser->parseOptions($options); + } + $options = $resolver->resolve($options); return $fieldType->render($field, $data, $options); } diff --git a/src/Bundle/Resources/config/services.xml b/src/Bundle/Resources/config/services.xml index 348f777b..f364d935 100644 --- a/src/Bundle/Resources/config/services.xml +++ b/src/Bundle/Resources/config/services.xml @@ -128,5 +128,8 @@ + + + diff --git a/src/Bundle/Resources/config/services/field_types.xml b/src/Bundle/Resources/config/services/field_types.xml index dc07f242..5b535898 100644 --- a/src/Bundle/Resources/config/services/field_types.xml +++ b/src/Bundle/Resources/config/services/field_types.xml @@ -15,6 +15,12 @@ + + + + + + %sylius_grid.timezone% diff --git a/src/Bundle/Resources/config/services/twig.xml b/src/Bundle/Resources/config/services/twig.xml index daabcc3a..30f0dc54 100644 --- a/src/Bundle/Resources/config/services/twig.xml +++ b/src/Bundle/Resources/config/services/twig.xml @@ -23,6 +23,7 @@ @SyliusGrid/_grid.html.twig %sylius.grid.templates.action% %sylius.grid.templates.filter% + diff --git a/src/Bundle/Tests/Functional/GridUiTest.php b/src/Bundle/Tests/Functional/GridUiTest.php index 627768e1..90723e68 100644 --- a/src/Bundle/Tests/Functional/GridUiTest.php +++ b/src/Bundle/Tests/Functional/GridUiTest.php @@ -41,6 +41,20 @@ public function it_shows_authors_grid(): void $this->assertCount(10, $this->getAuthorNamesFromResponse()); } + /** @test */ + public function it_shows_authors_ids(): void + { + $this->client->request('GET', '/authors/?limit=100'); + + $ids = $this->getAuthorIdsFromResponse(); + + $this->assertNotEmpty($ids); + $this->assertSame( + array_filter($ids, fn (string $id) => str_starts_with($id, '#')), + $ids, + ); + } + /** @test */ public function it_sorts_authors_by_name_ascending_by_default(): void { @@ -98,7 +112,7 @@ public function it_filters_books_by_title(): void $titles = $this->getBookTitlesFromResponse(); $this->assertCount(1, $titles); - $this->assertSame('Book 5', $titles[0]); + $this->assertSame('BOOK 5', $titles[0]); } /** @test */ @@ -112,7 +126,7 @@ public function it_filters_books_by_title_with_contains(): void $titles = $this->getBookTitlesFromResponse(); $this->assertCount(1, $titles); - $this->assertSame('Jurassic Park', $titles[0]); + $this->assertSame('JURASSIC PARK', $titles[0]); } /** @test */ @@ -125,7 +139,7 @@ public function it_filters_books_by_author(): void $titles = $this->getBookTitlesFromResponse(); $this->assertCount(2, $titles); - $this->assertSame('Jurassic Park', $titles[0]); + $this->assertSame('JURASSIC PARK', $titles[0]); } /** @test */ @@ -139,7 +153,7 @@ public function it_filters_books_by_authors(): void $titles = $this->getBookTitlesFromResponse(); $this->assertCount(3, $titles); - $this->assertSame('A Study in Scarlet', $titles[0]); + $this->assertSame('A STUDY IN SCARLET', $titles[0]); } /** @test */ @@ -152,7 +166,7 @@ public function it_filters_books_by_authors_nationality(): void $titles = $this->getBookTitlesFromResponse(); $this->assertCount(2, $titles); - $this->assertSame('Jurassic Park', $titles[0]); + $this->assertSame('JURASSIC PARK', $titles[0]); } /** @test */ @@ -165,7 +179,7 @@ public function it_filters_books_by_author_and_currency(): void $titles = $this->getBookTitlesFromResponse(); $this->assertCount(1, $titles); - $this->assertSame('Jurassic Park', $titles[0]); + $this->assertSame('JURASSIC PARK', $titles[0]); } /** @test */ @@ -274,6 +288,16 @@ private function getBookAuthorNationalitiesFromResponse(): array ); } + /** @return string[] */ + private function getAuthorIdsFromResponse(): array + { + return $this->getCrawler() + ->filter('[data-test-id]') + ->each( + fn (Crawler $node): string => $node->text(), + ); + } + /** @return string[] */ private function getAuthorNamesFromResponse(): array { diff --git a/src/Bundle/Tests/Unit/Parser/OptionsParserTest.php b/src/Bundle/Tests/Unit/Parser/OptionsParserTest.php new file mode 100644 index 00000000..c3e33864 --- /dev/null +++ b/src/Bundle/Tests/Unit/Parser/OptionsParserTest.php @@ -0,0 +1,57 @@ +assertInstanceOf(OptionsParserInterface::class, new OptionsParser()); + } + + public function testItParsesOptionsWithCallable(): void + { + $options = (new OptionsParser())->parseOptions([ + 'type' => 'callable', + 'option' => [ + 'callable' => 'callable:strtoupper', + ], + 'label' => 'app.ui.id', + ]); + + $this->assertArrayHasKey('type', $options); + $this->assertArrayHasKey('option', $options); + $this->assertArrayHasKey('label', $options); + + $this->assertIsCallable($options['option']['callable'] ?? null); + } + + public function testItFailsWhileParsingOptionsWithInvalidCallable(): void + { + $this->expectException(InvalidArgumentException::class); + + $options = (new OptionsParser())->parseOptions([ + 'type' => 'callable', + 'option' => [ + 'callable' => 'callable:foobar', + ], + 'label' => 'app.ui.id', + ]); + } +} diff --git a/src/Bundle/spec/Renderer/TwigGridRendererSpec.php b/src/Bundle/spec/Renderer/TwigGridRendererSpec.php index 1aeedb45..717a3c19 100644 --- a/src/Bundle/spec/Renderer/TwigGridRendererSpec.php +++ b/src/Bundle/spec/Renderer/TwigGridRendererSpec.php @@ -16,6 +16,7 @@ use PhpSpec\ObjectBehavior; use Prophecy\Argument; use Sylius\Bundle\GridBundle\Form\Registry\FormTypeRegistryInterface; +use Sylius\Bundle\GridBundle\Parser\OptionsParserInterface; use Sylius\Component\Grid\Definition\Action; use Sylius\Component\Grid\Definition\Field; use Sylius\Component\Grid\FieldTypes\FieldTypeInterface; @@ -35,6 +36,7 @@ function let( ServiceRegistryInterface $fieldsRegistry, FormFactoryInterface $formFactory, FormTypeRegistryInterface $formTypeRegistry, + OptionsParserInterface $optionsParser, ): void { $actionTemplates = [ 'link' => '@SyliusGrid/Action/_link.html.twig', @@ -52,6 +54,7 @@ function let( '"@SyliusGrid/default"', $actionTemplates, $filterTemplates, + $optionsParser, ); } @@ -94,6 +97,7 @@ function it_renders_a_field_with_data_via_appropriate_field_type( Field $field, ServiceRegistryInterface $fieldsRegistry, FieldTypeInterface $fieldType, + OptionsParserInterface $optionsParser, ): void { $field->getType()->willReturn('string'); $fieldsRegistry->get('string')->willReturn($fieldType); @@ -103,6 +107,51 @@ function it_renders_a_field_with_data_via_appropriate_field_type( }) ; + $field->getOptions()->willReturn([ + 'foo' => 'bar', + ]); + $optionsParser->parseOptions(['foo' => 'bar'])->willReturn(['foo' => 'bar']); + $fieldType->render($field, 'Value', ['foo' => 'bar'])->willReturn('Value'); + + $this->renderField($gridView, $field, 'Value')->shouldReturn('Value'); + } + + function it_renders_a_field_with_data_via_appropriate_field_type_when_no_option_parser_is_provided( + Environment $twig, + ServiceRegistryInterface $fieldsRegistry, + FormFactoryInterface $formFactory, + FormTypeRegistryInterface $formTypeRegistry, + GridViewInterface $gridView, + Field $field, + FieldTypeInterface $fieldType, + ): void { + $actionTemplates = [ + 'link' => '@SyliusGrid/Action/_link.html.twig', + 'form' => '@SyliusGrid/Action/_form.html.twig', + ]; + $filterTemplates = [ + StringFilter::NAME => '@SyliusGrid/Filter/_string.html.twig', + ]; + + $this->beConstructedWith( + $twig, + $fieldsRegistry, + $formFactory, + $formTypeRegistry, + '"@SyliusGrid/default"', + $actionTemplates, + $filterTemplates, + null, + ); + + $field->getType()->willReturn('string'); + $fieldsRegistry->get('string')->willReturn($fieldType); + $fieldType->configureOptions(Argument::type(OptionsResolver::class)) + ->will(function ($args) { + $args[0]->setRequired('foo'); + }) + ; + $field->getOptions()->willReturn([ 'foo' => 'bar', ]); diff --git a/src/Component/Exception/ExceptionInterface.php b/src/Component/Exception/ExceptionInterface.php new file mode 100644 index 00000000..b449cba7 --- /dev/null +++ b/src/Component/Exception/ExceptionInterface.php @@ -0,0 +1,18 @@ +dataExtractor->get($field, $data); + $value = call_user_func($options['callable'], $value); + + try { + $value = (string) $value; + } catch (\Throwable $e) { + throw new UnexpectedValueException(\sprintf( + 'Callable field (name "%s") returned value could not be converted to string: "%s".', + $field->getName(), + $e->getMessage(), + )); + } + + if ($options['htmlspecialchars']) { + $value = htmlspecialchars($value); + } + + return $value; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setRequired('callable'); + $resolver->setAllowedTypes('callable', 'callable'); + + $resolver->setDefault('htmlspecialchars', true); + $resolver->setAllowedTypes('htmlspecialchars', 'bool'); + } +} diff --git a/src/Component/spec/FieldTypes/CallableFieldTypeSpec.php b/src/Component/spec/FieldTypes/CallableFieldTypeSpec.php new file mode 100644 index 00000000..0123d21d --- /dev/null +++ b/src/Component/spec/FieldTypes/CallableFieldTypeSpec.php @@ -0,0 +1,105 @@ +beConstructedWith($dataExtractor); + } + + function it_is_a_grid_field_type(): void + { + $this->shouldImplement(FieldTypeInterface::class); + } + + function it_uses_data_extractor_to_obtain_data_and_passes_it_to_a_callable_with_htmlspecialchars( + DataExtractorInterface $dataExtractor, + Field $field, + ): void { + $dataExtractor->get($field, ['foo' => 'bar'])->willReturn('bar'); + + $this->render($field, ['foo' => 'bar'], [ + 'callable' => fn (string $value): string => "$value", + 'htmlspecialchars' => true, + ])->shouldReturn('<strong>bar</strong>'); + } + + function it_uses_data_extractor_to_obtain_data_and_passes_it_to_a_callable_without_htmlspecialchars( + DataExtractorInterface $dataExtractor, + Field $field, + ): void { + $dataExtractor->get($field, ['foo' => 'bar'])->willReturn('bar'); + + $this->render($field, ['foo' => 'bar'], [ + 'callable' => fn (string $value): string => "$value", + 'htmlspecialchars' => false, + ])->shouldReturn('bar'); + } + + function it_uses_data_extractor_to_obtain_data_and_passes_it_to_a_function_callable( + DataExtractorInterface $dataExtractor, + Field $field, + ): void { + $dataExtractor->get($field, ['foo' => 'bar'])->willReturn('bar'); + + $this->render($field, ['foo' => 'bar'], [ + 'callable' => 'strtoupper', + 'htmlspecialchars' => true, + ])->shouldReturn('BAR'); + } + + function it_uses_data_extractor_to_obtain_data_and_passes_it_to_a_static_callable( + DataExtractorInterface $dataExtractor, + Field $field, + ): void { + $dataExtractor->get($field, ['foo' => 'BAR'])->willReturn('BAR'); + + $this->render($field, ['foo' => 'BAR'], [ + 'callable' => [self::class, 'callable'], + 'htmlspecialchars' => true, + ])->shouldReturn('bar'); + } + + function it_throws_an_exception_when_a_callable_return_value_cannot_be_casted_to_string( + DataExtractorInterface $dataExtractor, + Field $field, + ): void { + $field->getName()->willReturn('id'); + $dataExtractor->get($field, ['foo' => 'bar'])->willReturn('BAR'); + + $this + ->shouldThrow(UnexpectedValueException::class) + ->during('render', [ + $field, + ['foo' => 'bar'], + [ + 'callable' => fn () => new \stdclass(), + 'htmlspecialchars' => true, + ], + ]); + } + + static function callable(mixed $value): string + { + return strtolower($value); + } +} diff --git a/tests/Application/config/sylius/grids.yaml b/tests/Application/config/sylius/grids.yaml index beb008f6..b234503c 100644 --- a/tests/Application/config/sylius/grids.yaml +++ b/tests/Application/config/sylius/grids.yaml @@ -33,7 +33,9 @@ sylius_grid: title: asc fields: title: - type: string + type: callable + options: + callable: "callable:strtoupper" label: Title sortable: ~ author: @@ -60,10 +62,11 @@ sylius_grid: name: asc fields: id: - type: string + type: callable + options: + callable: "callable:App\\Helper\\GridHelper::addHashPrefix" label: ID sortable: ~ - enabled: false name: type: string label: Name diff --git a/tests/Application/config/sylius/grids/author.php b/tests/Application/config/sylius/grids/author.php index 89e7203f..e75264f7 100644 --- a/tests/Application/config/sylius/grids/author.php +++ b/tests/Application/config/sylius/grids/author.php @@ -11,6 +11,7 @@ declare(strict_types=1); +use Sylius\Bundle\GridBundle\Builder\Field\CallableField; use Sylius\Bundle\GridBundle\Builder\Field\StringField; use Sylius\Bundle\GridBundle\Builder\Filter\StringFilter; use Sylius\Bundle\GridBundle\Builder\GridBuilder; @@ -22,9 +23,8 @@ ->addFilter(StringFilter::create('name')) ->orderBy('name', 'asc') ->addField( - StringField::create('id') - ->setSortable(true) - ->setEnabled(false), + CallableField::create('id', ['App\\Helper\\GridHelper', 'addHashPrefix']) + ->setSortable(true), ) ->addField( StringField::create('name') diff --git a/tests/Application/config/sylius/grids/book.php b/tests/Application/config/sylius/grids/book.php index 460521a3..7f1e1b74 100644 --- a/tests/Application/config/sylius/grids/book.php +++ b/tests/Application/config/sylius/grids/book.php @@ -14,6 +14,7 @@ use App\Entity\Author; use App\Entity\Book; use App\Grid\Builder\NationalityFilter; +use Sylius\Bundle\GridBundle\Builder\Field\CallableField; use Sylius\Bundle\GridBundle\Builder\Field\StringField; use Sylius\Bundle\GridBundle\Builder\Filter\EntityFilter; use Sylius\Bundle\GridBundle\Builder\Filter\SelectFilter; @@ -48,7 +49,7 @@ ) ->orderBy('title', 'asc') ->addField( - StringField::create('title') + CallableField::create('title', 'strtoupper') ->setLabel('Title') ->setSortable(true), ) diff --git a/tests/Application/src/Grid/AuthorGrid.php b/tests/Application/src/Grid/AuthorGrid.php index f930efab..f779f75b 100644 --- a/tests/Application/src/Grid/AuthorGrid.php +++ b/tests/Application/src/Grid/AuthorGrid.php @@ -13,6 +13,8 @@ namespace App\Grid; +use App\Helper\GridHelper; +use Sylius\Bundle\GridBundle\Builder\Field\CallableField; use Sylius\Bundle\GridBundle\Builder\Field\StringField; use Sylius\Bundle\GridBundle\Builder\Filter\Filter; use Sylius\Bundle\GridBundle\Builder\GridBuilderInterface; @@ -44,9 +46,8 @@ public function buildGrid(GridBuilderInterface $gridBuilder): void ->addFilter(Filter::create('name', 'string')) ->orderBy('name', 'asc') ->addField( - StringField::create('id') - ->setSortable(true) - ->setEnabled(false), + CallableField::create('id', GridHelper::addHashPrefix(...)) + ->setSortable(true), ) ->addField( StringField::create('name') diff --git a/tests/Application/src/Grid/BookGrid.php b/tests/Application/src/Grid/BookGrid.php index 9c0ee24c..4359d619 100644 --- a/tests/Application/src/Grid/BookGrid.php +++ b/tests/Application/src/Grid/BookGrid.php @@ -22,6 +22,7 @@ use Sylius\Bundle\GridBundle\Builder\Action\UpdateAction; use Sylius\Bundle\GridBundle\Builder\ActionGroup\ItemActionGroup; use Sylius\Bundle\GridBundle\Builder\ActionGroup\MainActionGroup; +use Sylius\Bundle\GridBundle\Builder\Field\CallableField; use Sylius\Bundle\GridBundle\Builder\Field\StringField; use Sylius\Bundle\GridBundle\Builder\Filter\Filter; use Sylius\Bundle\GridBundle\Builder\GridBuilderInterface; @@ -73,7 +74,7 @@ public function buildGrid(GridBuilderInterface $gridBuilder): void ) ->orderBy('title', 'asc') ->addField( - StringField::create('title') + CallableField::create('title', 'strtoupper') ->setLabel('Title') ->setSortable(true), ) diff --git a/tests/Application/src/Helper/GridHelper.php b/tests/Application/src/Helper/GridHelper.php new file mode 100644 index 00000000..b4761edf --- /dev/null +++ b/tests/Application/src/Helper/GridHelper.php @@ -0,0 +1,22 @@ +