From ed7b4057e19f7c09fe120dc791558cd3e954e6e5 Mon Sep 17 00:00:00 2001 From: Tomasz Kryszan Date: Thu, 9 Jul 2026 07:03:34 +0200 Subject: [PATCH 1/8] Extended ibexa.Table with identity props, headline, table class and column options --- .../admin/twig_components/table.html.twig | 16 +++-- .../Templating/Twig/Components/Table.php | 61 +++++++++++++++++-- .../Twig/Components/Table/Column.php | 5 ++ 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/bundle/Resources/views/themes/admin/twig_components/table.html.twig b/src/bundle/Resources/views/themes/admin/twig_components/table.html.twig index 5652c25..5b65e8b 100644 --- a/src/bundle/Resources/views/themes/admin/twig_components/table.html.twig +++ b/src/bundle/Resources/views/themes/admin/twig_components/table.html.twig @@ -1,11 +1,16 @@ {% trans_default_domain 'ibexa_admin_ui' %} -
- +{% block header %}{% if headline is not null %}
+
{{ headline }}
+ {% block header_actions %}{% endblock %} +
+{% endif %}{% endblock %}
{% block before_table %}{% endblock %} + +
{% for column in columns %} - {% for column in columns %} - {% endfor %} @@ -43,5 +48,6 @@ {% endblock empty_state_row %} {% endfor %} -
+ {{ this.renderLabel(column)|raw }} @@ -17,7 +22,7 @@ {% for item in data %}
+ {{ this.renderCell(column, item)|raw }}
+ {% block after_table %}{% endblock %} +
diff --git a/src/bundle/Templating/Twig/Components/Table.php b/src/bundle/Templating/Twig/Components/Table.php index 0150a7a..5f6f8f8 100644 --- a/src/bundle/Templating/Twig/Components/Table.php +++ b/src/bundle/Templating/Twig/Components/Table.php @@ -18,10 +18,37 @@ )] final class Table { + private const string BASE_CLASS = 'ibexa-table table'; + private const string DEFAULT_MODIFIER_CLASS = 'ibexa-table--last-column-sticky'; + + /** + * Identifies the table to PostMount listeners. Guard on this instead of + * {@see getDataType()} when different tables share a row type or may be empty. + */ + public ?string $type = null; + + /** Sub-flavour of a table type (e.g. "draft"/"published"/"archived" for one type). */ + public ?string $variant = null; + + /** + * Rendered as the table header when non-null; PostMount listeners may overwrite it + * to replace the headline of a table they contribute columns to. + */ + public ?string $headline = null; + + /** + * Modifier CSS classes for the element; null keeps the default + * "ibexa-table--last-column-sticky". The "ibexa-table table" base is always applied. + */ + public ?string $class = null; + /** @var iterable */ #[ExposeInTemplate] private iterable $data = []; + /** @var iterable|null */ + private ?iterable $fullData = null; + /** @var class-string|null */ private ?string $dataType = null; @@ -35,13 +62,17 @@ final class Table private ?array $orderedColumns = null; /** - * @param iterable $data + * @param iterable $data rows rendered by the table (e.g. the current pagination page) + * @param iterable|null $fullData whole dataset the rendered rows were taken from; + * lets listeners decide whether their column applies independently of pagination */ - public function mount(iterable $data = []): void + public function mount(iterable $data = [], ?iterable $fullData = null): void { if ($data !== []) { $this->data = $data; } + + $this->fullData = $fullData; } /** @@ -52,6 +83,20 @@ public function getData(): iterable return $this->data; } + /** + * @return iterable + */ + public function getFullData(): iterable + { + return $this->fullData ?? $this->data; + } + + #[ExposeInTemplate('table_class')] + public function getTableClass(): string + { + return trim(self::BASE_CLASS . ' ' . ($this->class ?? self::DEFAULT_MODIFIER_CLASS)); + } + /** * @return class-string|null */ @@ -84,15 +129,23 @@ private function orderColumns(): array /** * @phpstan-param callable(Column): string $label * @phpstan-param callable(mixed, Column): string $renderer + * + * @param array $options presentation hints, see {@see Column::__construct()} */ - public function addColumn(string $identifier, callable $label, callable $renderer, int $priority = 0): self - { + public function addColumn( + string $identifier, + callable $label, + callable $renderer, + int $priority = 0, + array $options = [] + ): self { $this->orderedColumns = null; $this->columns[$identifier] = new Column( $identifier, $label(...), $renderer(...), $priority, + $options, ); return $this; diff --git a/src/bundle/Templating/Twig/Components/Table/Column.php b/src/bundle/Templating/Twig/Components/Table/Column.php index 9c10f9d..772673e 100644 --- a/src/bundle/Templating/Twig/Components/Table/Column.php +++ b/src/bundle/Templating/Twig/Components/Table/Column.php @@ -15,12 +15,17 @@ /** * @phpstan-param Closure(Column): string $label * @phpstan-param Closure(mixed, Column): string $renderer + * + * @param array $options presentation hints consumed by the table template; + * supported keys: "header_class" (extra CSS classes for the column's
), + * "cell_class" (extra CSS classes for the column's ) */ public function __construct( public string $identifier, public Closure $label, public Closure $renderer, public int $priority = 0, + public array $options = [], ) { } } From 37f55ab34f9098ef0250226e6d00c780a224b41c Mon Sep 17 00:00:00 2001 From: Tomasz Kryszan Date: Thu, 9 Jul 2026 07:03:48 +0200 Subject: [PATCH 2/8] [Tests] Added coverage for table extensions and form widget cell rendering --- composer.json | 3 +- .../Templating/Twig/Components/TableTest.php | 97 +++++++++++ ...FormAwareTwigComponentsIbexaTestKernel.php | 75 +++++++++ .../Twig/Components/Fixtures/VersionRow.php | 18 +++ .../TableFormWidgetRenderingTest.php | 133 +++++++++++++++ .../Templating/Twig/Components/TableTest.php | 151 ++++++++++++++++++ .../__snapshots__/table_with_headline.html | 21 +++ 7 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 tests/bundle/Templating/Twig/Components/TableTest.php create mode 100644 tests/integration/FormAwareTwigComponentsIbexaTestKernel.php create mode 100644 tests/integration/Templating/Twig/Components/Fixtures/VersionRow.php create mode 100644 tests/integration/Templating/Twig/Components/TableFormWidgetRenderingTest.php create mode 100644 tests/integration/Templating/Twig/Components/__snapshots__/table_with_headline.html diff --git a/composer.json b/composer.json index 231b92c..20ce82f 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,8 @@ "phpstan/phpstan": "^2.0", "phpstan/phpstan-phpunit": "^2.0", "phpstan/phpstan-symfony": "^2.0", - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^9.0", + "symfony/form": "^7.4" }, "autoload": { "psr-4": { diff --git a/tests/bundle/Templating/Twig/Components/TableTest.php b/tests/bundle/Templating/Twig/Components/TableTest.php new file mode 100644 index 0000000..a805dd3 --- /dev/null +++ b/tests/bundle/Templating/Twig/Components/TableTest.php @@ -0,0 +1,97 @@ +mount(); + + self::assertSame('ibexa-table table ibexa-table--last-column-sticky', $table->getTableClass()); + } + + public function testTableClassReplacesModifierWhenClassPropIsSet(): void + { + $table = new Table(); + $table->class = 'ibexa-table--draft-conflict mb-3'; + $table->mount(); + + self::assertSame('ibexa-table table ibexa-table--draft-conflict mb-3', $table->getTableClass()); + } + + public function testFullDataFallsBackToRenderedData(): void + { + $rows = [new \stdClass()]; + + $table = new Table(); + $table->mount($rows); + + self::assertSame($rows, $table->getFullData()); + } + + public function testFullDataIsExposedIndependentlyOfRenderedData(): void + { + $renderedPage = [new \stdClass()]; + $wholeDataset = [...$renderedPage, new \stdClass(), new \stdClass()]; + + $table = new Table(); + $table->mount($renderedPage, $wholeDataset); + + self::assertSame($renderedPage, $table->getData()); + self::assertSame($wholeDataset, $table->getFullData()); + } + + public function testIdentityPropsDefaultToNull(): void + { + $table = new Table(); + $table->mount(); + + self::assertNull($table->type); + self::assertNull($table->variant); + self::assertNull($table->headline); + } + + public function testAddColumnCarriesOptionsToColumn(): void + { + $table = new Table(); + $table->mount(); + $table->addColumn( + 'checkbox', + static fn (): string => 'Label', + static fn (): string => 'Cell', + 110, + ['header_class' => 'custom-header', 'cell_class' => 'custom-cell'] + ); + + $column = $table->getColumns()['checkbox']; + + self::assertSame( + ['header_class' => 'custom-header', 'cell_class' => 'custom-cell'], + $column->options + ); + } + + public function testAddColumnDefaultsToEmptyOptions(): void + { + $table = new Table(); + $table->mount(); + $table->addColumn( + 'plain', + static fn (): string => 'Label', + static fn (): string => 'Cell' + ); + + self::assertSame([], $table->getColumns()['plain']->options); + } +} diff --git a/tests/integration/FormAwareTwigComponentsIbexaTestKernel.php b/tests/integration/FormAwareTwigComponentsIbexaTestKernel.php new file mode 100644 index 0000000..c67605a --- /dev/null +++ b/tests/integration/FormAwareTwigComponentsIbexaTestKernel.php @@ -0,0 +1,75 @@ +load(__DIR__ . '/Resources/ibexa_test_config.yaml'); + $loader->load(static function (ContainerBuilder $container): void { + $container->loadFromExtension('framework', [ + 'form' => ['csrf_protection' => false], + ]); + + // TwigBundle skips its form integration when symfony/form is a dev-only + // dependency (ContainerBuilder::willBeAvailable()), so mirror + // twig-bundle/Resources/config/form.php here; ExtensionPass picks these + // definitions up and wires the twig.extension tag + theme paths. + $container->setParameter('twig.form.resources', ['form_div_layout.html.twig']); + $container->register('twig.extension.form', FormExtension::class) + ->setArguments([ + new Reference('translator', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), + ]); + $container->register('twig.form.engine', TwigRendererEngine::class) + ->setArguments(['%twig.form.resources%', new Reference('twig')]); + $container->register('twig.form.renderer', FormRenderer::class) + ->setArguments([ + new Reference('twig.form.engine'), + new Reference('security.csrf.token_manager', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), + ]) + ->addTag('twig.runtime'); + }); + } +} diff --git a/tests/integration/Templating/Twig/Components/Fixtures/VersionRow.php b/tests/integration/Templating/Twig/Components/Fixtures/VersionRow.php new file mode 100644 index 0000000..80128d5 --- /dev/null +++ b/tests/integration/Templating/Twig/Components/Fixtures/VersionRow.php @@ -0,0 +1,18 @@ +getServiceByClassName(SiteAccessServiceInterface::class); + assert($siteAccessService instanceof SiteAccessAware); + $siteAccess = $siteAccessService->get('admin'); + + $configResolver = self::getIbexaTestCore()->getServiceByClassName(ConfigResolverInterface::class); + self::assertInstanceOf(ChainConfigResolver::class, $configResolver); + + foreach ($configResolver->getAllResolvers() as $resolver) { + if ($resolver instanceof SiteAccessAware) { + $resolver->setSiteAccess($siteAccess); + } + } + } + + public function testFormWidgetCellIsRenderedOncePerRowAndNotDuplicatedByFormEnd(): void + { + $dispatcher = self::getContainer()->get(EventDispatcherInterface::class); + self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + $twig = self::getContainer()->get('twig'); + self::assertInstanceOf(Environment::class, $twig); + $formFactory = self::getIbexaTestCore()->getServiceByClassName(FormFactoryInterface::class); + + $versionsBuilder = $formFactory + ->createNamedBuilder('version_remove', FormType::class) + ->add('versions', FormType::class); + $versionsBuilder->get('versions') + ->add('1', CheckboxType::class, ['required' => false]) + ->add('2', CheckboxType::class, ['required' => false]); + $formView = $versionsBuilder->getForm()->createView(); + + $cellTemplate = $twig->createTemplate( + '{% block checkbox_cell %}{{ form_widget(form.versions[version_no], {attr: {disabled: not can_delete}}) }}{% endblock %}' + ); + + $listener = static function (PostMountEvent $event) use ($cellTemplate, $formView): void { + $component = $event->getComponent(); + if (!$component instanceof Table) { + return; + } + + $component->addColumn( + 'checkbox', + static fn (): string => 'Checkbox Column Label', + static fn (VersionRow $item): string => $cellTemplate->renderBlock('checkbox_cell', [ + 'form' => $formView, + 'version_no' => (string)$item->versionNo, + 'can_delete' => $item->canDelete, + ]), + 110 + ); + }; + $dispatcher->addListener(PostMountEvent::class, $listener); + + try { + $deletableVersion = new VersionRow(versionNo: 1, canDelete: true); + $lockedVersion = new VersionRow(versionNo: 2, canDelete: false); + + $html = $twig + ->createTemplate( + '{{ form_start(form) }}' + . '{% component "ibexa.Table" with { data: rows } %}{% endcomponent %}' + . '{{ form_end(form) }}' + ) + ->render(['form' => $formView, 'rows' => [$deletableVersion, $lockedVersion]]); + } finally { + $dispatcher->removeListener(PostMountEvent::class, $listener); + } + + self::assertStringContainsString('Checkbox Column Label', $html); + self::assertSame( + 1, + substr_count($html, 'name="version_remove[versions][1]"'), + 'Widget for version 1 must be rendered exactly once (no form_end duplication)' + ); + self::assertSame( + 1, + substr_count($html, 'name="version_remove[versions][2]"'), + 'Widget for version 2 must be rendered exactly once (no form_end duplication)' + ); + self::assertDoesNotMatchRegularExpression( + '/]*name="version_remove\[versions\]\[1\]"[^>]*disabled/', + $html, + 'Deletable version checkbox must not be disabled' + ); + self::assertMatchesRegularExpression( + '/]*name="version_remove\[versions\]\[2\]"[^>]*disabled/', + $html, + 'Non-deletable version checkbox must carry the disabled attribute' + ); + } +} diff --git a/tests/integration/Templating/Twig/Components/TableTest.php b/tests/integration/Templating/Twig/Components/TableTest.php index d3104ef..0189c67 100644 --- a/tests/integration/Templating/Twig/Components/TableTest.php +++ b/tests/integration/Templating/Twig/Components/TableTest.php @@ -15,6 +15,7 @@ use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessAware; use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\UX\TwigComponent\Event\PostMountEvent; use Symfony\UX\TwigComponent\Event\PreMountEvent; use Symfony\UX\TwigComponent\Test\InteractsWithTwigComponents; @@ -143,6 +144,156 @@ public function testTableComponentAllowsAddingColumnsViaEvent(): void $dispatcher->removeListener(PreMountEvent::class, $listener); } + public function testTableComponentRendersHeadline(): void + { + $rendered = $this->renderTwigComponent( + name: 'ibexa.Table', + data: [ + 'data' => [], + 'headline' => 'Open drafts', + ], + ); + + $html = $rendered->toString(); + self::assertStringContainsString('
', $html); + self::assertStringContainsString('
Open drafts
', $html); + $this->assertMatchesSnapshot($html, 'table_with_headline'); + } + + public function testTableComponentHeadlineCanBeOverriddenByListener(): void + { + $dispatcher = self::getContainer()->get(EventDispatcherInterface::class); + self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + $listener = static function (PostMountEvent $event): void { + $component = $event->getComponent(); + if (!$component instanceof Table) { + return; + } + + $component->headline = 'Headline from listener'; + }; + $dispatcher->addListener(PostMountEvent::class, $listener); + + try { + $html = $this + ->renderTwigComponent( + name: 'ibexa.Table', + data: [ + 'data' => [], + 'headline' => 'Default headline', + ], + ) + ->toString(); + } finally { + $dispatcher->removeListener(PostMountEvent::class, $listener); + } + + self::assertStringContainsString('Headline from listener', $html); + self::assertStringNotContainsString('Default headline', $html); + } + + public function testTableComponentAppliesCustomTableClass(): void + { + $html = $this + ->renderTwigComponent( + name: 'ibexa.Table', + data: [ + 'data' => [], + 'class' => 'ibexa-table--draft-conflict', + ], + ) + ->toString(); + + self::assertStringContainsString('', $html); + self::assertStringNotContainsString('ibexa-table--last-column-sticky', $html); + } + + public function testTableComponentRendersColumnOptionClasses(): void + { + $dispatcher = self::getContainer()->get(EventDispatcherInterface::class); + self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + $listener = static function (PostMountEvent $event): void { + $component = $event->getComponent(); + if (!$component instanceof Table) { + return; + } + + $component->addColumn( + 'checkbox', + static fn (): string => 'Checkbox', + static fn (): string => 'X', + 110, + ['header_class' => 'ibexa-table__header-cell--checkbox', 'cell_class' => 'ibexa-table__cell--checkbox'] + ); + }; + $dispatcher->addListener(PostMountEvent::class, $listener); + + try { + $html = $this + ->renderTwigComponent( + name: 'ibexa.Table', + data: [ + 'data' => [new \stdClass()], + ], + ) + ->toString(); + } finally { + $dispatcher->removeListener(PostMountEvent::class, $listener); + } + + self::assertStringContainsString('ibexa-table__header-cell--checkbox', $html); + self::assertStringContainsString('ibexa-table__cell--checkbox', $html); + } + + public function testListenersCanGuardOnTypeAndUseFullData(): void + { + $dispatcher = self::getContainer()->get(EventDispatcherInterface::class); + self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + $listener = static function (PostMountEvent $event): void { + $component = $event->getComponent(); + if (!$component instanceof Table || $component->type !== 'versions') { + return; + } + + $datasetSize = iterator_count(new \ArrayIterator([...$component->getFullData()])); + $component->addColumn( + 'dataset_size', + static fn (): string => sprintf('Dataset of %d (%s)', $datasetSize, $component->variant), + static fn (): string => '-' + ); + }; + $dispatcher->addListener(PostMountEvent::class, $listener); + + try { + $renderedPage = [new \stdClass()]; + $html = $this + ->renderTwigComponent( + name: 'ibexa.Table', + data: [ + 'data' => $renderedPage, + 'fullData' => [...$renderedPage, new \stdClass(), new \stdClass()], + 'type' => 'versions', + 'variant' => 'draft', + ], + ) + ->toString(); + + $unguardedHtml = $this + ->renderTwigComponent( + name: 'ibexa.Table', + data: [ + 'data' => $renderedPage, + ], + ) + ->toString(); + } finally { + $dispatcher->removeListener(PostMountEvent::class, $listener); + } + + self::assertStringContainsString('Dataset of 3 (draft)', $html); + self::assertStringNotContainsString('Dataset of', $unguardedHtml); + } + public function testTableComponentRespectsColumnPriorityViaEvent(): void { $dispatcher = self::getContainer()->get(EventDispatcherInterface::class); diff --git a/tests/integration/Templating/Twig/Components/__snapshots__/table_with_headline.html b/tests/integration/Templating/Twig/Components/__snapshots__/table_with_headline.html new file mode 100644 index 0000000..c7628e8 --- /dev/null +++ b/tests/integration/Templating/Twig/Components/__snapshots__/table_with_headline.html @@ -0,0 +1,21 @@ + +
+
Open drafts
+
+
+
+ + + + + + + + + +
+ +
+
+
+
From f54a10aa2622ed79065ff9731393dcaf1b12b792 Mon Sep 17 00:00:00 2001 From: Tomasz Kryszan Date: Fri, 10 Jul 2026 12:02:54 +0200 Subject: [PATCH 3/8] Changed table full data to explicit nullable and simplified class prop --- .../Templating/Twig/Components/Table.php | 23 +++++++++++-------- .../Twig/Components/Table/Column.php | 9 +++++--- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/bundle/Templating/Twig/Components/Table.php b/src/bundle/Templating/Twig/Components/Table.php index 5f6f8f8..9ed4657 100644 --- a/src/bundle/Templating/Twig/Components/Table.php +++ b/src/bundle/Templating/Twig/Components/Table.php @@ -19,7 +19,6 @@ final class Table { private const string BASE_CLASS = 'ibexa-table table'; - private const string DEFAULT_MODIFIER_CLASS = 'ibexa-table--last-column-sticky'; /** * Identifies the table to PostMount listeners. Guard on this instead of @@ -37,10 +36,10 @@ final class Table public ?string $headline = null; /** - * Modifier CSS classes for the element; null keeps the default - * "ibexa-table--last-column-sticky". The "ibexa-table table" base is always applied. + * Modifier CSS classes for the
element; pass '' to render the bare base classes. + * The "ibexa-table table" base is always applied. */ - public ?string $class = null; + public string $class = 'ibexa-table--last-column-sticky'; /** @var iterable */ #[ExposeInTemplate] @@ -84,17 +83,21 @@ public function getData(): iterable } /** - * @return iterable + * Whole dataset the rendered rows were taken from, or null when the mount site + * did not provide one — callers decide explicitly whether falling back to + * {@see getData()} (page-scoped rows) is acceptable for their use case. + * + * @return iterable|null */ - public function getFullData(): iterable + public function getFullData(): ?iterable { - return $this->fullData ?? $this->data; + return $this->fullData; } #[ExposeInTemplate('table_class')] public function getTableClass(): string { - return trim(self::BASE_CLASS . ' ' . ($this->class ?? self::DEFAULT_MODIFIER_CLASS)); + return trim(self::BASE_CLASS . ' ' . $this->class); } /** @@ -129,8 +132,8 @@ private function orderColumns(): array /** * @phpstan-param callable(Column): string $label * @phpstan-param callable(mixed, Column): string $renderer - * - * @param array $options presentation hints, see {@see Column::__construct()} + * @phpstan-param array{header_class?: string, cell_class?: string} $options presentation + * hints, see {@see Column::__construct()} */ public function addColumn( string $identifier, diff --git a/src/bundle/Templating/Twig/Components/Table/Column.php b/src/bundle/Templating/Twig/Components/Table/Column.php index 772673e..ed7afc1 100644 --- a/src/bundle/Templating/Twig/Components/Table/Column.php +++ b/src/bundle/Templating/Twig/Components/Table/Column.php @@ -10,14 +10,16 @@ use Closure; +/** + * @phpstan-type ColumnOptions array{header_class?: string, cell_class?: string} + */ final readonly class Column { /** * @phpstan-param Closure(Column): string $label * @phpstan-param Closure(mixed, Column): string $renderer - * - * @param array $options presentation hints consumed by the table template; - * supported keys: "header_class" (extra CSS classes for the column's
), + * @phpstan-param ColumnOptions $options presentation hints consumed by the table template: + * "header_class" (extra CSS classes for the column's ), * "cell_class" (extra CSS classes for the column's ) */ public function __construct( @@ -25,6 +27,7 @@ public function __construct( public Closure $label, public Closure $renderer, public int $priority = 0, + /** @var ColumnOptions */ public array $options = [], ) { } From 00af09140fddba232449f814e9a3c7dc54cfdcb4 Mon Sep 17 00:00:00 2001 From: Tomasz Kryszan Date: Fri, 10 Jul 2026 12:02:54 +0200 Subject: [PATCH 4/8] [Tests] Adjusted table coverage to nullable full data and exposed test services --- .../Templating/Twig/Components/TableTest.php | 46 +++++++++++++------ ...FormAwareTwigComponentsIbexaTestKernel.php | 4 ++ .../TableFormWidgetRenderingTest.php | 6 +-- .../Templating/Twig/Components/TableTest.php | 17 +++---- .../TwigComponentsIbexaTestKernel.php | 4 ++ 5 files changed, 48 insertions(+), 29 deletions(-) diff --git a/tests/bundle/Templating/Twig/Components/TableTest.php b/tests/bundle/Templating/Twig/Components/TableTest.php index a805dd3..05acdb6 100644 --- a/tests/bundle/Templating/Twig/Components/TableTest.php +++ b/tests/bundle/Templating/Twig/Components/TableTest.php @@ -13,31 +13,49 @@ final class TableTest extends TestCase { - public function testTableClassDefaultsToLastColumnSticky(): void - { + /** + * @dataProvider provideTableClass + */ + public function testTableClassAppliesModifierToBaseClasses( + ?string $classProp, + string $expectedTableClass + ): void { $table = new Table(); + if ($classProp !== null) { + $table->class = $classProp; + } $table->mount(); - self::assertSame('ibexa-table table ibexa-table--last-column-sticky', $table->getTableClass()); + self::assertSame($expectedTableClass, $table->getTableClass()); } - public function testTableClassReplacesModifierWhenClassPropIsSet(): void + /** + * @return iterable + */ + public static function provideTableClass(): iterable { - $table = new Table(); - $table->class = 'ibexa-table--draft-conflict mb-3'; - $table->mount(); - - self::assertSame('ibexa-table table ibexa-table--draft-conflict mb-3', $table->getTableClass()); + yield 'default modifier' => [ + null, + 'ibexa-table table ibexa-table--last-column-sticky', + ]; + + yield 'custom modifier replaces the default' => [ + 'ibexa-table--draft-conflict mb-3', + 'ibexa-table table ibexa-table--draft-conflict mb-3', + ]; + + yield 'empty string clears the modifier' => [ + '', + 'ibexa-table table', + ]; } - public function testFullDataFallsBackToRenderedData(): void + public function testFullDataIsNullWhenMountSiteDoesNotProvideIt(): void { - $rows = [new \stdClass()]; - $table = new Table(); - $table->mount($rows); + $table->mount([new \stdClass()]); - self::assertSame($rows, $table->getFullData()); + self::assertNull($table->getFullData()); } public function testFullDataIsExposedIndependentlyOfRenderedData(): void diff --git a/tests/integration/FormAwareTwigComponentsIbexaTestKernel.php b/tests/integration/FormAwareTwigComponentsIbexaTestKernel.php index c67605a..edb86c6 100644 --- a/tests/integration/FormAwareTwigComponentsIbexaTestKernel.php +++ b/tests/integration/FormAwareTwigComponentsIbexaTestKernel.php @@ -19,9 +19,11 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\Form\FormRenderer; use Symfony\UX\TwigComponent\TwigComponentBundle; +use Twig\Environment; /** * Variant of {@see TwigComponentsIbexaTestKernel} with Symfony Forms enabled, for tests @@ -42,6 +44,8 @@ protected static function getExposedServicesByClass(): iterable yield SiteAccessServiceInterface::class; yield ConfigResolverInterface::class; yield FormFactoryInterface::class; + yield EventDispatcherInterface::class; + yield Environment::class; } public function registerContainerConfiguration(LoaderInterface $loader): void diff --git a/tests/integration/Templating/Twig/Components/TableFormWidgetRenderingTest.php b/tests/integration/Templating/Twig/Components/TableFormWidgetRenderingTest.php index a6fdf40..98f9edf 100644 --- a/tests/integration/Templating/Twig/Components/TableFormWidgetRenderingTest.php +++ b/tests/integration/Templating/Twig/Components/TableFormWidgetRenderingTest.php @@ -56,10 +56,8 @@ protected function setUp(): void public function testFormWidgetCellIsRenderedOncePerRowAndNotDuplicatedByFormEnd(): void { - $dispatcher = self::getContainer()->get(EventDispatcherInterface::class); - self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher); - $twig = self::getContainer()->get('twig'); - self::assertInstanceOf(Environment::class, $twig); + $dispatcher = self::getIbexaTestCore()->getServiceByClassName(EventDispatcherInterface::class); + $twig = self::getIbexaTestCore()->getServiceByClassName(Environment::class); $formFactory = self::getIbexaTestCore()->getServiceByClassName(FormFactoryInterface::class); $versionsBuilder = $formFactory diff --git a/tests/integration/Templating/Twig/Components/TableTest.php b/tests/integration/Templating/Twig/Components/TableTest.php index 0189c67..8ae2359 100644 --- a/tests/integration/Templating/Twig/Components/TableTest.php +++ b/tests/integration/Templating/Twig/Components/TableTest.php @@ -110,8 +110,7 @@ private function assertMatchesSnapshot(string $actual, string $snapshotName): vo public function testTableComponentAllowsAddingColumnsViaEvent(): void { - $dispatcher = self::getContainer()->get(EventDispatcherInterface::class); - self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + $dispatcher = self::getIbexaTestCore()->getServiceByClassName(EventDispatcherInterface::class); $listener = static function (PreMountEvent $event): void { $component = $event->getComponent(); if (!$component instanceof Table) { @@ -162,8 +161,7 @@ public function testTableComponentRendersHeadline(): void public function testTableComponentHeadlineCanBeOverriddenByListener(): void { - $dispatcher = self::getContainer()->get(EventDispatcherInterface::class); - self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + $dispatcher = self::getIbexaTestCore()->getServiceByClassName(EventDispatcherInterface::class); $listener = static function (PostMountEvent $event): void { $component = $event->getComponent(); if (!$component instanceof Table) { @@ -210,8 +208,7 @@ public function testTableComponentAppliesCustomTableClass(): void public function testTableComponentRendersColumnOptionClasses(): void { - $dispatcher = self::getContainer()->get(EventDispatcherInterface::class); - self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + $dispatcher = self::getIbexaTestCore()->getServiceByClassName(EventDispatcherInterface::class); $listener = static function (PostMountEvent $event): void { $component = $event->getComponent(); if (!$component instanceof Table) { @@ -247,15 +244,14 @@ public function testTableComponentRendersColumnOptionClasses(): void public function testListenersCanGuardOnTypeAndUseFullData(): void { - $dispatcher = self::getContainer()->get(EventDispatcherInterface::class); - self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + $dispatcher = self::getIbexaTestCore()->getServiceByClassName(EventDispatcherInterface::class); $listener = static function (PostMountEvent $event): void { $component = $event->getComponent(); if (!$component instanceof Table || $component->type !== 'versions') { return; } - $datasetSize = iterator_count(new \ArrayIterator([...$component->getFullData()])); + $datasetSize = iterator_count(new \ArrayIterator([...$component->getFullData() ?? $component->getData()])); $component->addColumn( 'dataset_size', static fn (): string => sprintf('Dataset of %d (%s)', $datasetSize, $component->variant), @@ -296,8 +292,7 @@ public function testListenersCanGuardOnTypeAndUseFullData(): void public function testTableComponentRespectsColumnPriorityViaEvent(): void { - $dispatcher = self::getContainer()->get(EventDispatcherInterface::class); - self::assertInstanceOf(EventDispatcherInterface::class, $dispatcher); + $dispatcher = self::getIbexaTestCore()->getServiceByClassName(EventDispatcherInterface::class); $listener = static function (PreMountEvent $event): void { $component = $event->getComponent(); if (!$component instanceof Table) { diff --git a/tests/integration/TwigComponentsIbexaTestKernel.php b/tests/integration/TwigComponentsIbexaTestKernel.php index 8bef1eb..454b6d2 100644 --- a/tests/integration/TwigComponentsIbexaTestKernel.php +++ b/tests/integration/TwigComponentsIbexaTestKernel.php @@ -14,7 +14,9 @@ use Ibexa\Contracts\Test\Core\IbexaTestKernel; use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface; use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\UX\TwigComponent\TwigComponentBundle; +use Twig\Environment; final class TwigComponentsIbexaTestKernel extends IbexaTestKernel { @@ -30,6 +32,8 @@ protected static function getExposedServicesByClass(): iterable { yield SiteAccessServiceInterface::class; yield ConfigResolverInterface::class; + yield EventDispatcherInterface::class; + yield Environment::class; } public function registerContainerConfiguration(LoaderInterface $loader): void From 436364c640bc1077843266097379b4d2e046bc48 Mon Sep 17 00:00:00 2001 From: Tomasz Kryszan Date: Fri, 10 Jul 2026 12:46:07 +0200 Subject: [PATCH 5/8] Improved table component output whitespace --- .../admin/twig_components/table.html.twig | 59 ++++++++++--------- .../__snapshots__/table_column_priority.html | 17 +++--- .../__snapshots__/table_empty_state.html | 15 +++-- .../__snapshots__/table_renders.html | 15 +++-- .../table_with_extra_columns.html | 13 ++-- .../__snapshots__/table_with_headline.html | 17 +++--- 6 files changed, 67 insertions(+), 69 deletions(-) diff --git a/src/bundle/Resources/views/themes/admin/twig_components/table.html.twig b/src/bundle/Resources/views/themes/admin/twig_components/table.html.twig index 5b65e8b..e68a6d2 100644 --- a/src/bundle/Resources/views/themes/admin/twig_components/table.html.twig +++ b/src/bundle/Resources/views/themes/admin/twig_components/table.html.twig @@ -1,53 +1,56 @@ {% trans_default_domain 'ibexa_admin_ui' %} - -{% block header %}{% if headline is not null %}
+{%~ block header %} +{%~ if headline is not null %} +
{{ headline }}
- {% block header_actions %}{% endblock %} + {%~ block header_actions %}{% endblock %}
-{% endif %}{% endblock %}
{% block before_table %}{% endblock %} - +{%~ endif %} +{%~ endblock %} +
+ {%~ block before_table %}{% endblock %} - {% for column in columns %} + {%~ for column in columns %} - {% endfor %} + {%~ endfor %} - {% for item in data %} + {%~ for item in data %} - {% for column in columns %} + {%~ for column in columns %} - {% endfor %} + {%~ endfor %} - {% else %} - {% block empty_state_row %} + {%~ else %} + {%~ block empty_state_row %} - {% endblock empty_state_row %} - {% endfor %} + {%~ endblock empty_state_row %} + {%~ endfor %} -
{{ this.renderLabel(column)|raw }}
{{ this.renderCell(column, item)|raw }}
- {% block empty_state_content %} - {% block empty_state_image %} - - {% endblock empty_state_image %} - {% block empty_state_text %} -
- {% block empty_state_title %}{% endblock empty_state_title %} - {% block empty_state_description %}{% endblock empty_state_description %} - {% block empty_state_extra_actions %}{% endblock empty_state_extra_actions %} -
- {% endblock empty_state_text %} - {% endblock empty_state_content %} + {%~ block empty_state_content %} + {%~ block empty_state_image %} + + {%~ endblock empty_state_image %} + {%~ block empty_state_text %} +
+ {%~ block empty_state_title %}{% endblock empty_state_title %} + {%~ block empty_state_description %}{% endblock empty_state_description %} + {%~ block empty_state_extra_actions %}{% endblock empty_state_extra_actions %} +
+ {%~ endblock empty_state_text %} + {%~ endblock empty_state_content %}
{% block after_table %}{% endblock %} - +
+ {%~ block after_table %}{% endblock %} diff --git a/tests/integration/Templating/Twig/Components/__snapshots__/table_column_priority.html b/tests/integration/Templating/Twig/Components/__snapshots__/table_column_priority.html index ebd5492..591df69 100644 --- a/tests/integration/Templating/Twig/Components/__snapshots__/table_column_priority.html +++ b/tests/integration/Templating/Twig/Components/__snapshots__/table_column_priority.html @@ -1,29 +1,28 @@ -
- - - + - - + - - - + +
+ High Priority Column + Low Priority Column
+
High + Low
diff --git a/tests/integration/Templating/Twig/Components/__snapshots__/table_empty_state.html b/tests/integration/Templating/Twig/Components/__snapshots__/table_empty_state.html index 0a24782..66ce6d8 100644 --- a/tests/integration/Templating/Twig/Components/__snapshots__/table_empty_state.html +++ b/tests/integration/Templating/Twig/Components/__snapshots__/table_empty_state.html @@ -1,18 +1,17 @@ -
- + - + + +
+
+ - +
- -
-
-
diff --git a/tests/integration/Templating/Twig/Components/__snapshots__/table_renders.html b/tests/integration/Templating/Twig/Components/__snapshots__/table_renders.html index 0a24782..66ce6d8 100644 --- a/tests/integration/Templating/Twig/Components/__snapshots__/table_renders.html +++ b/tests/integration/Templating/Twig/Components/__snapshots__/table_renders.html @@ -1,18 +1,17 @@ -
- + - + + +
+
+ - +
- -
-
-
diff --git a/tests/integration/Templating/Twig/Components/__snapshots__/table_with_extra_columns.html b/tests/integration/Templating/Twig/Components/__snapshots__/table_with_extra_columns.html index 0c0d0a0..1549645 100644 --- a/tests/integration/Templating/Twig/Components/__snapshots__/table_with_extra_columns.html +++ b/tests/integration/Templating/Twig/Components/__snapshots__/table_with_extra_columns.html @@ -1,21 +1,20 @@ -
- - + - - + - - + +
+ Extra Column Label
+
Value: Foo
diff --git a/tests/integration/Templating/Twig/Components/__snapshots__/table_with_headline.html b/tests/integration/Templating/Twig/Components/__snapshots__/table_with_headline.html index c7628e8..888e90c 100644 --- a/tests/integration/Templating/Twig/Components/__snapshots__/table_with_headline.html +++ b/tests/integration/Templating/Twig/Components/__snapshots__/table_with_headline.html @@ -1,21 +1,20 @@ -
Open drafts
-
+
- + - + + +
+
+ - +
- -
-
-
From cb0b724e02a8d92ce3d863a0e7d1024bf845dba3 Mon Sep 17 00:00:00 2001 From: Tomasz Kryszan Date: Fri, 10 Jul 2026 13:08:59 +0200 Subject: [PATCH 6/8] [Tests] Refactored table component test scaffolding --- .../Templating/Twig/Components/TableTest.php | 65 ++-- .../Templating/Twig/Components/TableTest.php | 344 ++++++++---------- 2 files changed, 188 insertions(+), 221 deletions(-) diff --git a/tests/bundle/Templating/Twig/Components/TableTest.php b/tests/bundle/Templating/Twig/Components/TableTest.php index 05acdb6..f7cc4d2 100644 --- a/tests/bundle/Templating/Twig/Components/TableTest.php +++ b/tests/bundle/Templating/Twig/Components/TableTest.php @@ -24,7 +24,6 @@ public function testTableClassAppliesModifierToBaseClasses( if ($classProp !== null) { $table->class = $classProp; } - $table->mount(); self::assertSame($expectedTableClass, $table->getTableClass()); } @@ -73,43 +72,53 @@ public function testFullDataIsExposedIndependentlyOfRenderedData(): void public function testIdentityPropsDefaultToNull(): void { $table = new Table(); - $table->mount(); self::assertNull($table->type); self::assertNull($table->variant); self::assertNull($table->headline); } - public function testAddColumnCarriesOptionsToColumn(): void + /** + * @dataProvider provideColumnOptions + * + * @param array{header_class?: string, cell_class?: string}|null $options null = argument omitted + * @param array{header_class?: string, cell_class?: string} $expectedOptions + */ + public function testAddColumnCarriesOptionsToColumn(?array $options, array $expectedOptions): void { $table = new Table(); - $table->mount(); - $table->addColumn( - 'checkbox', - static fn (): string => 'Label', - static fn (): string => 'Cell', - 110, - ['header_class' => 'custom-header', 'cell_class' => 'custom-cell'] - ); - - $column = $table->getColumns()['checkbox']; - - self::assertSame( - ['header_class' => 'custom-header', 'cell_class' => 'custom-cell'], - $column->options - ); + if ($options === null) { + $table->addColumn( + 'checkbox', + static fn (): string => 'Label', + static fn (): string => 'Cell' + ); + } else { + $table->addColumn( + 'checkbox', + static fn (): string => 'Label', + static fn (): string => 'Cell', + 110, + $options + ); + } + + self::assertSame($expectedOptions, $table->getColumns()['checkbox']->options); } - public function testAddColumnDefaultsToEmptyOptions(): void + /** + * @return iterable + */ + public static function provideColumnOptions(): iterable { - $table = new Table(); - $table->mount(); - $table->addColumn( - 'plain', - static fn (): string => 'Label', - static fn (): string => 'Cell' - ); - - self::assertSame([], $table->getColumns()['plain']->options); + yield 'passed options land on the column' => [ + ['header_class' => 'custom-header', 'cell_class' => 'custom-cell'], + ['header_class' => 'custom-header', 'cell_class' => 'custom-cell'], + ]; + + yield 'omitted options default to an empty set' => [ + null, + [], + ]; } } diff --git a/tests/integration/Templating/Twig/Components/TableTest.php b/tests/integration/Templating/Twig/Components/TableTest.php index 8ae2359..e638eb9 100644 --- a/tests/integration/Templating/Twig/Components/TableTest.php +++ b/tests/integration/Templating/Twig/Components/TableTest.php @@ -55,14 +55,8 @@ public function testTableComponentMounts(): void public function testTableComponentRenders(): void { - $rendered = $this->renderTwigComponent( - name: 'ibexa.Table', - data: [ - 'data' => [], - ], - ); + $html = $this->renderTable(['data' => []]); - $html = $rendered->toString(); self::assertStringContainsString('ibexa-table', $html); $this->assertMatchesSnapshot($html, 'table_renders'); } @@ -83,77 +77,43 @@ public function testTableComponentInfersDataType(): void public function testTableComponentRendersEmptyState(): void { - $rendered = $this->renderTwigComponent( - name: 'ibexa.Table', - data: [ - 'data' => [], - ], - ); - - $this->assertMatchesSnapshot($rendered->toString(), 'table_empty_state'); - } - - private function assertMatchesSnapshot(string $actual, string $snapshotName): void - { - $snapshotPath = __DIR__ . '/__snapshots__/' . $snapshotName . '.html'; - - if (!file_exists($snapshotPath)) { - file_put_contents($snapshotPath, $actual); - self::markTestIncomplete('Snapshot created: ' . $snapshotPath); - } - - $expected = file_get_contents($snapshotPath); - - // Normalize whitespace for easier comparison if needed, or just compare strictly - self::assertSame($expected, $actual, 'Snapshot comparison failed for ' . $snapshotName); + $this->assertMatchesSnapshot($this->renderTable(['data' => []]), 'table_empty_state'); } public function testTableComponentAllowsAddingColumnsViaEvent(): void { - $dispatcher = self::getIbexaTestCore()->getServiceByClassName(EventDispatcherInterface::class); - $listener = static function (PreMountEvent $event): void { - $component = $event->getComponent(); - if (!$component instanceof Table) { - return; - } - - $component->addColumn( - 'extra_column', - static fn (): string => 'Extra Column Label', - static fn (object $item): string => 'Value: ' . ($item->name ?? 'unknown') - ); - }; - $dispatcher->addListener(PreMountEvent::class, $listener); - $item = new \stdClass(); $item->name = 'Foo'; - $rendered = $this->renderTwigComponent( - name: 'ibexa.Table', - data: [ - 'data' => [$item], - ], + $html = $this->withListener( + PreMountEvent::class, + static function (PreMountEvent $event): void { + $component = $event->getComponent(); + if (!$component instanceof Table) { + return; + } + + $component->addColumn( + 'extra_column', + static fn (): string => 'Extra Column Label', + static fn (object $item): string => 'Value: ' . ($item->name ?? 'unknown') + ); + }, + fn (): string => $this->renderTable(['data' => [$item]]) ); - $html = $rendered->toString(); self::assertStringContainsString('Extra Column Label', $html); self::assertStringContainsString('Value: Foo', $html); $this->assertMatchesSnapshot($html, 'table_with_extra_columns'); - - $dispatcher->removeListener(PreMountEvent::class, $listener); } public function testTableComponentRendersHeadline(): void { - $rendered = $this->renderTwigComponent( - name: 'ibexa.Table', - data: [ - 'data' => [], - 'headline' => 'Open drafts', - ], - ); + $html = $this->renderTable([ + 'data' => [], + 'headline' => 'Open drafts', + ]); - $html = $rendered->toString(); self::assertStringContainsString('
', $html); self::assertStringContainsString('
Open drafts
', $html); $this->assertMatchesSnapshot($html, 'table_with_headline'); @@ -161,30 +121,21 @@ public function testTableComponentRendersHeadline(): void public function testTableComponentHeadlineCanBeOverriddenByListener(): void { - $dispatcher = self::getIbexaTestCore()->getServiceByClassName(EventDispatcherInterface::class); - $listener = static function (PostMountEvent $event): void { - $component = $event->getComponent(); - if (!$component instanceof Table) { - return; - } - - $component->headline = 'Headline from listener'; - }; - $dispatcher->addListener(PostMountEvent::class, $listener); - - try { - $html = $this - ->renderTwigComponent( - name: 'ibexa.Table', - data: [ - 'data' => [], - 'headline' => 'Default headline', - ], - ) - ->toString(); - } finally { - $dispatcher->removeListener(PostMountEvent::class, $listener); - } + $html = $this->withListener( + PostMountEvent::class, + static function (PostMountEvent $event): void { + $component = $event->getComponent(); + if (!$component instanceof Table) { + return; + } + + $component->headline = 'Headline from listener'; + }, + fn (): string => $this->renderTable([ + 'data' => [], + 'headline' => 'Default headline', + ]) + ); self::assertStringContainsString('Headline from listener', $html); self::assertStringNotContainsString('Default headline', $html); @@ -192,15 +143,10 @@ public function testTableComponentHeadlineCanBeOverriddenByListener(): void public function testTableComponentAppliesCustomTableClass(): void { - $html = $this - ->renderTwigComponent( - name: 'ibexa.Table', - data: [ - 'data' => [], - 'class' => 'ibexa-table--draft-conflict', - ], - ) - ->toString(); + $html = $this->renderTable([ + 'data' => [], + 'class' => 'ibexa-table--draft-conflict', + ]); self::assertStringContainsString('', $html); self::assertStringNotContainsString('ibexa-table--last-column-sticky', $html); @@ -208,35 +154,24 @@ public function testTableComponentAppliesCustomTableClass(): void public function testTableComponentRendersColumnOptionClasses(): void { - $dispatcher = self::getIbexaTestCore()->getServiceByClassName(EventDispatcherInterface::class); - $listener = static function (PostMountEvent $event): void { - $component = $event->getComponent(); - if (!$component instanceof Table) { - return; - } - - $component->addColumn( - 'checkbox', - static fn (): string => 'Checkbox', - static fn (): string => 'X', - 110, - ['header_class' => 'ibexa-table__header-cell--checkbox', 'cell_class' => 'ibexa-table__cell--checkbox'] - ); - }; - $dispatcher->addListener(PostMountEvent::class, $listener); - - try { - $html = $this - ->renderTwigComponent( - name: 'ibexa.Table', - data: [ - 'data' => [new \stdClass()], - ], - ) - ->toString(); - } finally { - $dispatcher->removeListener(PostMountEvent::class, $listener); - } + $html = $this->withListener( + PostMountEvent::class, + static function (PostMountEvent $event): void { + $component = $event->getComponent(); + if (!$component instanceof Table) { + return; + } + + $component->addColumn( + 'checkbox', + static fn (): string => 'Checkbox', + static fn (): string => 'X', + 110, + ['header_class' => 'ibexa-table__header-cell--checkbox', 'cell_class' => 'ibexa-table__cell--checkbox'] + ); + }, + fn (): string => $this->renderTable(['data' => [new \stdClass()]]) + ); self::assertStringContainsString('ibexa-table__header-cell--checkbox', $html); self::assertStringContainsString('ibexa-table__cell--checkbox', $html); @@ -244,47 +179,33 @@ public function testTableComponentRendersColumnOptionClasses(): void public function testListenersCanGuardOnTypeAndUseFullData(): void { - $dispatcher = self::getIbexaTestCore()->getServiceByClassName(EventDispatcherInterface::class); - $listener = static function (PostMountEvent $event): void { - $component = $event->getComponent(); - if (!$component instanceof Table || $component->type !== 'versions') { - return; - } - - $datasetSize = iterator_count(new \ArrayIterator([...$component->getFullData() ?? $component->getData()])); - $component->addColumn( - 'dataset_size', - static fn (): string => sprintf('Dataset of %d (%s)', $datasetSize, $component->variant), - static fn (): string => '-' - ); - }; - $dispatcher->addListener(PostMountEvent::class, $listener); - - try { - $renderedPage = [new \stdClass()]; - $html = $this - ->renderTwigComponent( - name: 'ibexa.Table', - data: [ - 'data' => $renderedPage, - 'fullData' => [...$renderedPage, new \stdClass(), new \stdClass()], - 'type' => 'versions', - 'variant' => 'draft', - ], - ) - ->toString(); - - $unguardedHtml = $this - ->renderTwigComponent( - name: 'ibexa.Table', - data: [ - 'data' => $renderedPage, - ], - ) - ->toString(); - } finally { - $dispatcher->removeListener(PostMountEvent::class, $listener); - } + $renderedPage = [new \stdClass()]; + + [$html, $unguardedHtml] = $this->withListener( + PostMountEvent::class, + static function (PostMountEvent $event): void { + $component = $event->getComponent(); + if (!$component instanceof Table || $component->type !== 'versions') { + return; + } + + $datasetSize = iterator_count(new \ArrayIterator([...$component->getFullData() ?? $component->getData()])); + $component->addColumn( + 'dataset_size', + static fn (): string => sprintf('Dataset of %d (%s)', $datasetSize, $component->variant), + static fn (): string => '-' + ); + }, + fn (): array => [ + $this->renderTable([ + 'data' => $renderedPage, + 'fullData' => [...$renderedPage, new \stdClass(), new \stdClass()], + 'type' => 'versions', + 'variant' => 'draft', + ]), + $this->renderTable(['data' => $renderedPage]), + ] + ); self::assertStringContainsString('Dataset of 3 (draft)', $html); self::assertStringNotContainsString('Dataset of', $unguardedHtml); @@ -292,43 +213,80 @@ public function testListenersCanGuardOnTypeAndUseFullData(): void public function testTableComponentRespectsColumnPriorityViaEvent(): void { - $dispatcher = self::getIbexaTestCore()->getServiceByClassName(EventDispatcherInterface::class); - $listener = static function (PreMountEvent $event): void { - $component = $event->getComponent(); - if (!$component instanceof Table) { - return; - } - - $component->addColumn( - 'low_priority', - static fn (): string => 'Low Priority Column', - static fn (): string => 'Low', - 10 - ); - $component->addColumn( - 'high_priority', - static fn (): string => 'High Priority Column', - static fn (): string => 'High', - 100 - ); - }; - $dispatcher->addListener(PreMountEvent::class, $listener); - - $rendered = $this->renderTwigComponent( - name: 'ibexa.Table', - data: [ - 'data' => [new \stdClass()], - ], + $html = $this->withListener( + PreMountEvent::class, + static function (PreMountEvent $event): void { + $component = $event->getComponent(); + if (!$component instanceof Table) { + return; + } + + $component->addColumn( + 'low_priority', + static fn (): string => 'Low Priority Column', + static fn (): string => 'Low', + 10 + ); + $component->addColumn( + 'high_priority', + static fn (): string => 'High Priority Column', + static fn (): string => 'High', + 100 + ); + }, + fn (): string => $this->renderTable(['data' => [new \stdClass()]]) ); - $html = $rendered->toString(); self::assertGreaterThan( strpos($html, 'High Priority Column'), strpos($html, 'Low Priority Column'), 'High Priority Column should come before Low Priority Column' ); $this->assertMatchesSnapshot($html, 'table_column_priority'); + } - $dispatcher->removeListener(PreMountEvent::class, $listener); + /** + * @param array $componentData + */ + private function renderTable(array $componentData): string + { + return $this->renderTwigComponent(name: 'ibexa.Table', data: $componentData)->toString(); + } + + /** + * Runs $render with the listener attached and ALWAYS detaches it afterwards, + * so a failing test cannot leak the listener into subsequent ones. + * + * @template TResult + * + * @param class-string $eventClass + * @param \Closure(): TResult $render + * + * @return TResult + */ + private function withListener(string $eventClass, \Closure $listener, \Closure $render): mixed + { + $dispatcher = self::getIbexaTestCore()->getServiceByClassName(EventDispatcherInterface::class); + $dispatcher->addListener($eventClass, $listener); + + try { + return $render(); + } finally { + $dispatcher->removeListener($eventClass, $listener); + } + } + + private function assertMatchesSnapshot(string $actual, string $snapshotName): void + { + $snapshotPath = __DIR__ . '/__snapshots__/' . $snapshotName . '.html'; + + if (!file_exists($snapshotPath)) { + file_put_contents($snapshotPath, $actual); + self::markTestIncomplete('Snapshot created: ' . $snapshotPath); + } + + $expected = file_get_contents($snapshotPath); + + self::assertSame($expected, $actual, 'Snapshot comparison failed for ' . $snapshotName); } } From 58ee1832c676c38fb9f215d17fa3f47162d5257b Mon Sep 17 00:00:00 2001 From: Tomasz Kryszan Date: Fri, 10 Jul 2026 15:28:17 +0200 Subject: [PATCH 7/8] Reused Column options phpstan type as single source of truth in Table --- src/bundle/Templating/Twig/Components/Table.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/bundle/Templating/Twig/Components/Table.php b/src/bundle/Templating/Twig/Components/Table.php index 9ed4657..82bd282 100644 --- a/src/bundle/Templating/Twig/Components/Table.php +++ b/src/bundle/Templating/Twig/Components/Table.php @@ -12,6 +12,9 @@ use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; +/** + * @phpstan-import-type ColumnOptions from Column + */ #[AsTwigComponent( name: 'ibexa.Table', template: '@ibexadesign/twig_components/table.html.twig', @@ -132,8 +135,7 @@ private function orderColumns(): array /** * @phpstan-param callable(Column): string $label * @phpstan-param callable(mixed, Column): string $renderer - * @phpstan-param array{header_class?: string, cell_class?: string} $options presentation - * hints, see {@see Column::__construct()} + * @phpstan-param ColumnOptions $options presentation hints, see {@see Column::__construct()} */ public function addColumn( string $identifier, From 031ed76f6c47b015db28320fa547f4c856cf51ed Mon Sep 17 00:00:00 2001 From: Tomasz Kryszan Date: Fri, 10 Jul 2026 15:32:46 +0200 Subject: [PATCH 8/8] Fixed trailing whitespace in table cell class attributes --- .../themes/admin/twig_components/table.html.twig | 12 ++++++++++-- .../__snapshots__/table_column_priority.html | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/bundle/Resources/views/themes/admin/twig_components/table.html.twig b/src/bundle/Resources/views/themes/admin/twig_components/table.html.twig index e68a6d2..5d13b84 100644 --- a/src/bundle/Resources/views/themes/admin/twig_components/table.html.twig +++ b/src/bundle/Resources/views/themes/admin/twig_components/table.html.twig @@ -13,7 +13,11 @@ {%~ for column in columns %} - {%~ for column in columns %} - {%~ endfor %} diff --git a/tests/integration/Templating/Twig/Components/__snapshots__/table_column_priority.html b/tests/integration/Templating/Twig/Components/__snapshots__/table_column_priority.html index 591df69..f8ae428 100644 --- a/tests/integration/Templating/Twig/Components/__snapshots__/table_column_priority.html +++ b/tests/integration/Templating/Twig/Components/__snapshots__/table_column_priority.html @@ -2,7 +2,7 @@
+ {{ this.renderLabel(column)|raw }} @@ -25,7 +29,11 @@ {%~ for item in data %}
+ {{ this.renderCell(column, item)|raw }}
- -
+ High Priority Column @@ -16,7 +16,7 @@
+ High