From 3fe352e3e2ee5cc05dff5c4fe796bf2592496f75 Mon Sep 17 00:00:00 2001 From: Florian Merle Date: Wed, 6 Nov 2024 17:42:12 +0100 Subject: [PATCH] add expression field --- composer.json | 5 +- docs/field_types.md | 77 +++++++++++++++++++ src/Bundle/Builder/Field/ExpressionField.php | 28 +++++++ .../SyliusGridExtension.php | 17 ++++ src/Bundle/Resources/config/services.xml | 1 + .../config/services/expression_language.xml | 33 ++++++++ .../Resources/config/services/field_types.xml | 7 ++ src/Bundle/Tests/Functional/GridUiTest.php | 31 ++++++++ .../Provider/ServiceGridProviderTest.php | 1 + .../Attribute/AsExpressionProvider.php | 20 +++++ .../Attribute/AsExpressionVariables.php | 20 +++++ .../ExpressionEvaluator.php | 39 ++++++++++ .../ExpressionEvaluatorInterface.php | 22 ++++++ .../ExpressionLanguageFactory.php | 41 ++++++++++ .../VariablesCollectionAggregate.php | 38 +++++++++ .../VariablesCollectionInterface.php | 25 ++++++ .../FieldTypes/ExpressionFieldType.php | 50 ++++++++++++ src/Component/composer.json | 3 +- .../ArrayVariablesCollectionStub.php | 28 +++++++ .../ExpressionEvaluatorSpec.php | 45 +++++++++++ .../FieldTypes/ExpressionFieldTypeSpec.php | 66 ++++++++++++++++ tests/Application/config/services.yaml | 3 + tests/Application/config/sylius/grids.yaml | 5 ++ .../Application/config/sylius/grids/book.php | 5 ++ .../src/ExpressionLanguage/Provider.php | 32 ++++++++ .../src/ExpressionLanguage/Variables.php | 28 +++++++ tests/Application/src/Grid/BookGrid.php | 5 ++ 27 files changed, 672 insertions(+), 3 deletions(-) create mode 100644 src/Bundle/Builder/Field/ExpressionField.php create mode 100644 src/Bundle/Resources/config/services/expression_language.xml create mode 100644 src/Component/Attribute/AsExpressionProvider.php create mode 100644 src/Component/Attribute/AsExpressionVariables.php create mode 100644 src/Component/ExpressionLanguage/ExpressionEvaluator.php create mode 100644 src/Component/ExpressionLanguage/ExpressionEvaluatorInterface.php create mode 100644 src/Component/ExpressionLanguage/ExpressionLanguageFactory.php create mode 100644 src/Component/ExpressionLanguage/VariablesCollectionAggregate.php create mode 100644 src/Component/ExpressionLanguage/VariablesCollectionInterface.php create mode 100644 src/Component/FieldTypes/ExpressionFieldType.php create mode 100644 src/Component/spec/ExpressionLanguage/ArrayVariablesCollectionStub.php create mode 100644 src/Component/spec/ExpressionLanguage/ExpressionEvaluatorSpec.php create mode 100644 src/Component/spec/FieldTypes/ExpressionFieldTypeSpec.php create mode 100644 tests/Application/src/ExpressionLanguage/Provider.php create mode 100644 tests/Application/src/ExpressionLanguage/Variables.php diff --git a/composer.json b/composer.json index befba289..946bea89 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.0", "symfony/deprecation-contracts": "^2.2 || ^3.1", "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0", + "symfony/expression-language": "^5.4 || ^6.0 || ^7.0", "symfony/form": "^5.4 || ^6.4 || ^7.0", "symfony/framework-bundle": "^5.4 || ^6.4 || ^7.0", "symfony/http-kernel": "^5.4 || ^6.4 || ^7.0", @@ -97,8 +98,8 @@ }, "autoload-dev": { "psr-4": { - "Sylius\\Bundle\\GridBundle\\spec\\": "src/Bundle/spec/", - "Sylius\\Component\\Grid\\spec\\": "src/Component/spec/", + "spec\\Sylius\\Bundle\\GridBundle\\": "src/Bundle/spec/", + "spec\\Sylius\\Component\\Grid\\": "src/Component/spec/", "App\\": "tests/Application/src/", "AppBundle\\": "src/Bundle/test/src/AppBundle/", "App\\Tests\\Tmp\\": "tests/Application/tmp/" diff --git a/docs/field_types.md b/docs/field_types.md index f12d664d..80a83da9 100644 --- a/docs/field_types.md +++ b/docs/field_types.md @@ -319,3 +319,80 @@ $field->setOptions([ // Your options here ]); ``` + +Expression +---------- + +The **Expression** column provides flexibility by allowing you to specify an expression that will be evaluated on the fly. +This feature uses Symfony's expression language, making it versatile and powerful without needing to create additional callbacks or templates. + +The expression will be evaluated with the following context: + +- `value`: The value of the row. + +You can also control whether special characters in the output are escaped by setting the `htmlspecialchars` option. +By default, this is enabled, but you can disable it if the output contains HTML elements that should render as HTML. + +
Yaml + +```yaml +# config/packages/sylius_grid.yaml + +sylius_grid: + grids: + app_user: + fields: + price: + type: expression + label: app.ui.price + options: + expression: 'value ~ "$"' + + role: + type: expression + label: app.ui.price + options: + expression: '"" ~ value ~ ""' + htmlspecialchars: false + + most_exensive_order_total: + type: expression + label: app.ui.most_exensive_order_total + options: + expression: 'container.get("sylius.repository.order").findMostExpensiveOrder(value).getTotal()' +``` + +
+ +
PHP + +```php +addGrid(GridBuilder::create('app_user', '%app.model.user.class%') + ->addField( + ExpressionField::create('price', 'value ~ "$"') + ->setLabel('app.ui.price') + ) + ->addField( + ExpressionField::create('role', '"" ~ value ~ ""', htmlspecialchars: false) + ->setLabel('app.ui.role') + ) + ->addField( + ExpressionField::create( + 'most_expensive_order_total', + 'container.get("sylius.repository.order").findMostExpensiveOrder(value).getTotal()', + ) + ->setLabel('app.ui.most_exensive_order_total') + ) + ); +}; +``` + +
diff --git a/src/Bundle/Builder/Field/ExpressionField.php b/src/Bundle/Builder/Field/ExpressionField.php new file mode 100644 index 00000000..03fd93f3 --- /dev/null +++ b/src/Bundle/Builder/Field/ExpressionField.php @@ -0,0 +1,28 @@ +setOption('expression', $expression) + ->setOption('htmlspecialchars', $htmlspecialchars) + ; + } +} diff --git a/src/Bundle/DependencyInjection/SyliusGridExtension.php b/src/Bundle/DependencyInjection/SyliusGridExtension.php index 493e1dab..e23fd320 100644 --- a/src/Bundle/DependencyInjection/SyliusGridExtension.php +++ b/src/Bundle/DependencyInjection/SyliusGridExtension.php @@ -16,9 +16,12 @@ use Sylius\Bundle\CurrencyBundle\SyliusCurrencyBundle; use Sylius\Bundle\GridBundle\Grid\GridInterface; use Sylius\Bundle\GridBundle\SyliusGridBundle; +use Sylius\Component\Grid\Attribute\AsExpressionProvider; +use Sylius\Component\Grid\Attribute\AsExpressionVariables; use Sylius\Component\Grid\Data\DataProviderInterface; use Sylius\Component\Grid\Filtering\FilterInterface; use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; @@ -67,6 +70,20 @@ public function load(array $configs, ContainerBuilder $container): void $container->registerForAutoconfiguration(DataProviderInterface::class) ->addTag('sylius.grid_data_provider') ; + + $container->registerAttributeForAutoconfiguration( + AsExpressionVariables::class, + static function (ChildDefinition $definition, AsExpressionVariables $attribute, \Reflector $reflector): void { + $definition->addTag(AsExpressionVariables::SERVICE_TAG); + }, + ); + + $container->registerAttributeForAutoconfiguration( + AsExpressionProvider::class, + static function (ChildDefinition $definition, AsExpressionProvider $attribute, \Reflector $reflector): void { + $definition->addTag(AsExpressionProvider::SERVICE_TAG); + }, + ); } public function getConfiguration(array $config, ContainerBuilder $container): Configuration diff --git a/src/Bundle/Resources/config/services.xml b/src/Bundle/Resources/config/services.xml index b281b1f3..853b82d4 100644 --- a/src/Bundle/Resources/config/services.xml +++ b/src/Bundle/Resources/config/services.xml @@ -13,6 +13,7 @@ + diff --git a/src/Bundle/Resources/config/services/expression_language.xml b/src/Bundle/Resources/config/services/expression_language.xml new file mode 100644 index 00000000..b5c23505 --- /dev/null +++ b/src/Bundle/Resources/config/services/expression_language.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Bundle/Resources/config/services/field_types.xml b/src/Bundle/Resources/config/services/field_types.xml index cd5c4199..26a8c3ee 100644 --- a/src/Bundle/Resources/config/services/field_types.xml +++ b/src/Bundle/Resources/config/services/field_types.xml @@ -22,6 +22,13 @@ + + + + + + + diff --git a/src/Bundle/Tests/Functional/GridUiTest.php b/src/Bundle/Tests/Functional/GridUiTest.php index 627768e1..067975d0 100644 --- a/src/Bundle/Tests/Functional/GridUiTest.php +++ b/src/Bundle/Tests/Functional/GridUiTest.php @@ -101,6 +101,27 @@ public function it_filters_books_by_title(): void $this->assertSame('Book 5', $titles[0]); } + /** @test */ + public function it_shows_books_prices(): void + { + $this->client->request('GET', '/books/'); + + $prices = $this->getBookPriceFromResponse(); + + $this->assertEquals([ + '42 €', + '10 £', + '42 €', + '10 £', + '42 €', + '42 €', + '42 €', + '10 £', + '42 €', + '42 €', + ], $prices); + } + /** @test */ public function it_filters_books_by_title_with_contains(): void { @@ -274,6 +295,16 @@ private function getBookAuthorNationalitiesFromResponse(): array ); } + /** @return string[] */ + private function getBookPriceFromResponse(): array + { + return $this->getCrawler() + ->filter('[data-test-price]') + ->each( + fn (Crawler $node): string => $node->text(), + ); + } + /** @return string[] */ private function getAuthorNamesFromResponse(): array { diff --git a/src/Bundle/Tests/Provider/ServiceGridProviderTest.php b/src/Bundle/Tests/Provider/ServiceGridProviderTest.php index 16bfe5e1..dfccc78f 100644 --- a/src/Bundle/Tests/Provider/ServiceGridProviderTest.php +++ b/src/Bundle/Tests/Provider/ServiceGridProviderTest.php @@ -35,6 +35,7 @@ public function test_grids_inheritance(): void $this->assertEquals([ 'title', 'author', + 'price', 'id', ], array_keys($gridDefinition->getFields())); diff --git a/src/Component/Attribute/AsExpressionProvider.php b/src/Component/Attribute/AsExpressionProvider.php new file mode 100644 index 00000000..3c07ea36 --- /dev/null +++ b/src/Component/Attribute/AsExpressionProvider.php @@ -0,0 +1,20 @@ +expressionLanguage->evaluate( + $expression, + array_merge( + $this->variablesCollection->getCollection(), + $variables, + ), + ); + } +} diff --git a/src/Component/ExpressionLanguage/ExpressionEvaluatorInterface.php b/src/Component/ExpressionLanguage/ExpressionEvaluatorInterface.php new file mode 100644 index 00000000..e190f402 --- /dev/null +++ b/src/Component/ExpressionLanguage/ExpressionEvaluatorInterface.php @@ -0,0 +1,22 @@ + $providers */ + public function __construct(private $providers) + { + Assert::allIsInstanceOf($this->providers, ExpressionFunctionProviderInterface::class); + } + + public function __invoke(): ExpressionLanguage + { + $expressionLanguage = new ExpressionLanguage(); + + foreach ($this->providers as $provider) { + $expressionLanguage->registerProvider($provider); + } + + return $expressionLanguage; + } +} diff --git a/src/Component/ExpressionLanguage/VariablesCollectionAggregate.php b/src/Component/ExpressionLanguage/VariablesCollectionAggregate.php new file mode 100644 index 00000000..0dd49bcd --- /dev/null +++ b/src/Component/ExpressionLanguage/VariablesCollectionAggregate.php @@ -0,0 +1,38 @@ + $variablesCollection */ + public function __construct(private $variablesCollection) + { + Assert::allIsInstanceOf($this->variablesCollection, VariablesCollectionInterface::class); + } + + public function getCollection(): array + { + $collections = []; + foreach ($this->variablesCollection as $collection) { + $collections[] = $collection->getCollection(); + } + + return array_merge(...$collections); + } +} diff --git a/src/Component/ExpressionLanguage/VariablesCollectionInterface.php b/src/Component/ExpressionLanguage/VariablesCollectionInterface.php new file mode 100644 index 00000000..05dc2dad --- /dev/null +++ b/src/Component/ExpressionLanguage/VariablesCollectionInterface.php @@ -0,0 +1,25 @@ + + */ + public function getCollection(): array; +} diff --git a/src/Component/FieldTypes/ExpressionFieldType.php b/src/Component/FieldTypes/ExpressionFieldType.php new file mode 100644 index 00000000..614eeb0c --- /dev/null +++ b/src/Component/FieldTypes/ExpressionFieldType.php @@ -0,0 +1,50 @@ +expressionEvaluator->evaluateExpression($options['expression'], [ + 'value' => $this->dataExtractor->get($field, $data), + ]); + + if ($options['htmlspecialchars']) { + $value = htmlspecialchars($value); + } + + return $value; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setRequired('expression'); + $resolver->setAllowedTypes('expression', 'string'); + + $resolver->setDefault('htmlspecialchars', true); + $resolver->setAllowedTypes('htmlspecialchars', 'bool'); + } +} diff --git a/src/Component/composer.json b/src/Component/composer.json index e649542f..0204448d 100644 --- a/src/Component/composer.json +++ b/src/Component/composer.json @@ -34,6 +34,7 @@ "symfony/config": "^5.4 || ^6.4 || ^7.0", "symfony/deprecation-contracts": "^2.2", "symfony/event-dispatcher": "^5.4 || ^6.4 || ^7.0", + "symfony/expression-language": "^5.4 || ^6.0 || ^7.0", "symfony/http-kernel": "^5.4 || ^6.4 || ^7.0", "symfony/options-resolver": "^5.4 || ^6.0 || ^7.0", "webmozart/assert": "^1.9" @@ -54,7 +55,7 @@ }, "autoload-dev": { "psr-4": { - "Sylius\\Component\\Grid\\spec\\": "spec/" + "spec\\Sylius\\Component\\Grid\\": "spec/" } } } diff --git a/src/Component/spec/ExpressionLanguage/ArrayVariablesCollectionStub.php b/src/Component/spec/ExpressionLanguage/ArrayVariablesCollectionStub.php new file mode 100644 index 00000000..96bc91b2 --- /dev/null +++ b/src/Component/spec/ExpressionLanguage/ArrayVariablesCollectionStub.php @@ -0,0 +1,28 @@ +variables; + } +} diff --git a/src/Component/spec/ExpressionLanguage/ExpressionEvaluatorSpec.php b/src/Component/spec/ExpressionLanguage/ExpressionEvaluatorSpec.php new file mode 100644 index 00000000..cda83f47 --- /dev/null +++ b/src/Component/spec/ExpressionLanguage/ExpressionEvaluatorSpec.php @@ -0,0 +1,45 @@ +beConstructedWith( + new ExpressionLanguage(), + new ArrayVariablesCollectionStub([ + 'foo' => 69, + ]), + ); + } + + function it_evaluate_simple_expression(): void + { + $this->evaluateExpression('1 + 2')->shouldReturn(3); + } + + function it_evaluate_simple_expression_with_variable(): void + { + $this->evaluateExpression('1 + 2 + bar', ['bar' => 10])->shouldReturn(13); + } + + function it_evaluate_simple_expression_with_variable_from_variable_collection(): void + { + $this->evaluateExpression('foo ~ " lyon"')->shouldReturn('69 lyon'); + } +} diff --git a/src/Component/spec/FieldTypes/ExpressionFieldTypeSpec.php b/src/Component/spec/FieldTypes/ExpressionFieldTypeSpec.php new file mode 100644 index 00000000..808650be --- /dev/null +++ b/src/Component/spec/FieldTypes/ExpressionFieldTypeSpec.php @@ -0,0 +1,66 @@ +beConstructedWith( + $dataExtractor, + new ExpressionEvaluator( + new ExpressionLanguage(), + new ArrayVariablesCollectionStub([]), + ), + ); + } + + function it_is_a_grid_field_type(): void + { + $this->shouldImplement(FieldTypeInterface::class); + } + + function it_uses_data_extractor_to_obtain_data_and_evaluates_it_with_htmlspecialchars( + DataExtractorInterface $dataExtractor, + Field $field, + ): void { + $dataExtractor->get($field, ['foo' => 5])->willReturn(5); + + $this->render($field, ['foo' => 5], [ + 'expression' => '"" ~ value * 100 ~ "$"', + 'htmlspecialchars' => true, + ])->shouldReturn('<strong>500$</strong>'); + } + + function it_uses_data_extractor_to_obtain_data_and_evaluates_it_without_htmlspecialchars( + DataExtractorInterface $dataExtractor, + Field $field, + ): void { + $dataExtractor->get($field, ['foo' => 5])->willReturn(5); + + $this->render($field, ['foo' => 5], [ + 'expression' => '"" ~ value * 100 ~ "$"', + 'htmlspecialchars' => false, + ])->shouldReturn('500$'); + } +} diff --git a/tests/Application/config/services.yaml b/tests/Application/config/services.yaml index 8853a837..d8d9e5bc 100644 --- a/tests/Application/config/services.yaml +++ b/tests/Application/config/services.yaml @@ -29,3 +29,6 @@ services: App\BoardGameBlog\: resource: '../src/BoardGameBlog' + + App\ExpressionLanguage\Variables: ~ + App\ExpressionLanguage\Provider: ~ diff --git a/tests/Application/config/sylius/grids.yaml b/tests/Application/config/sylius/grids.yaml index beb008f6..092c5589 100644 --- a/tests/Application/config/sylius/grids.yaml +++ b/tests/Application/config/sylius/grids.yaml @@ -46,6 +46,11 @@ sylius_grid: label: Nationality path: author.nationality.name sortable: author.nationality.name + price: + type: expression + label: Price + options: + expression: 'value.getAmount() / cents_per_unit ~ " " ~ get_currency_symbol(value.getCurrencyCode())' limits: [10, 5, 15] app_author: diff --git a/tests/Application/config/sylius/grids/book.php b/tests/Application/config/sylius/grids/book.php index 460521a3..acdf78c6 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\ExpressionField; use Sylius\Bundle\GridBundle\Builder\Field\StringField; use Sylius\Bundle\GridBundle\Builder\Filter\EntityFilter; use Sylius\Bundle\GridBundle\Builder\Filter\SelectFilter; @@ -64,6 +65,10 @@ ->setPath('author.nationality.name') ->setSortable(true, 'author.nationality.name'), ) + ->addField( + ExpressionField::create('price', 'value.getAmount() / cents_per_unit ~ " " ~ get_currency_symbol(value.getCurrencyCode())') + ->setLabel('Price'), + ) ->setLimits([10, 5, 15]), ); }; diff --git a/tests/Application/src/ExpressionLanguage/Provider.php b/tests/Application/src/ExpressionLanguage/Provider.php new file mode 100644 index 00000000..13ea8980 --- /dev/null +++ b/tests/Application/src/ExpressionLanguage/Provider.php @@ -0,0 +1,32 @@ + null, fn ($arguments, string $code): string => match ($code) { + 'EUR' => '€', + 'GBP' => '£', + }), + ]; + } +} diff --git a/tests/Application/src/ExpressionLanguage/Variables.php b/tests/Application/src/ExpressionLanguage/Variables.php new file mode 100644 index 00000000..f5a201b7 --- /dev/null +++ b/tests/Application/src/ExpressionLanguage/Variables.php @@ -0,0 +1,28 @@ + 100, + ]; + } +} diff --git a/tests/Application/src/Grid/BookGrid.php b/tests/Application/src/Grid/BookGrid.php index 9c0ee24c..1094b1ef 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\ExpressionField; use Sylius\Bundle\GridBundle\Builder\Field\StringField; use Sylius\Bundle\GridBundle\Builder\Filter\Filter; use Sylius\Bundle\GridBundle\Builder\GridBuilderInterface; @@ -110,6 +111,10 @@ public function buildGrid(GridBuilderInterface $gridBuilder): void DeleteAction::create(), ), ) + ->addField( + ExpressionField::create('price', 'value.getAmount() / cents_per_unit ~ " " ~ get_currency_symbol(value.getCurrencyCode())') + ->setLabel('Price'), + ) ->setLimits([10, 5, 15]) ; }