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 @@
+