From 156fbf9e190422bf01d9e7e45863a9df78c8a027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Fri, 6 Feb 2026 10:43:20 +0100 Subject: [PATCH 1/3] IBX-10990: Introduced `ibexa.Table` component --- composer.json | 1 + .../Resources/config/services/twig.yaml | 2 + .../themes/admin/components/table.html.twig | 45 ++++++ .../Templating/Twig/Components/Table.php | 132 +++++++++++++++++ .../Twig/Components/Table/Column.php | 23 +++ tests/integration/AdminUiIbexaTestKernel.php | 30 ++-- .../Templating/Twig/Components/TableTest.php | 136 ++++++++++++++++++ 7 files changed, 357 insertions(+), 12 deletions(-) create mode 100644 src/bundle/Resources/views/themes/admin/components/table.html.twig create mode 100644 src/bundle/Templating/Twig/Components/Table.php create mode 100644 src/bundle/Templating/Twig/Components/Table/Column.php create mode 100644 tests/integration/Templating/Twig/Components/TableTest.php diff --git a/composer.json b/composer.json index 7f240ca0fb..8e13aa912a 100644 --- a/composer.json +++ b/composer.json @@ -50,6 +50,7 @@ "symfony/security-core": "^7.3", "symfony/security-http": "^7.3", "symfony/translation": "^7.3", + "symfony/ux-twig-component": "^2.32", "symfony/validator": "^7.3", "symfony/webpack-encore-bundle": "^2.2", "symfony/yaml": "^7.3", diff --git a/src/bundle/Resources/config/services/twig.yaml b/src/bundle/Resources/config/services/twig.yaml index 21d61534a4..c3626febc1 100644 --- a/src/bundle/Resources/config/services/twig.yaml +++ b/src/bundle/Resources/config/services/twig.yaml @@ -37,3 +37,5 @@ services: Ibexa\Bundle\AdminUi\Templating\Twig\LocationExtension: tags: - { name: twig.extension } + + Ibexa\Bundle\AdminUi\Templating\Twig\Components\Table: ~ diff --git a/src/bundle/Resources/views/themes/admin/components/table.html.twig b/src/bundle/Resources/views/themes/admin/components/table.html.twig new file mode 100644 index 0000000000..ba6c38d789 --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/components/table.html.twig @@ -0,0 +1,45 @@ +
+ + + + {% for column in columns %} + + {% endfor %} + + + + {% for item in data %} + + {% for column in columns %} + + {% endfor %} + + {% else %} + + + + {% endfor %} + +
+ + {{ column.label|raw }} + +
+ {{ this.renderCell(column, item)|raw }} +
+
+
+ +
+

{{ this.emptyStateTitle|trans }}

+ {% if this.emptyStateDescription %} +

{{ this.emptyStateDescription|trans }}

+ {% endif %} + {% if this.emptyStateExtraActions %} +
+ {{ this.emptyStateExtraActions|raw }} +
+ {% endif %} +
+
+
diff --git a/src/bundle/Templating/Twig/Components/Table.php b/src/bundle/Templating/Twig/Components/Table.php new file mode 100644 index 0000000000..bd665dc231 --- /dev/null +++ b/src/bundle/Templating/Twig/Components/Table.php @@ -0,0 +1,132 @@ + + */ + public iterable $data = []; + + public string $type = 'default'; + + /** @var class-string|null */ + private ?string $dataType = null; + + public TranslatableMessage $emptyStateTitle; + + public ?TranslatableMessage $emptyStateDescription = null; + + public ?string $emptyStateExtraActions = null; + + /** @var array */ + public array $parameters = []; + + /** + * @var array + */ + private array $columns = []; + + public function __construct() + { + $this->emptyStateTitle = new TranslatableMessage('search.no_results.title', [], 'ibexa_admin_ui'); + } + + /** + * @param iterable $data + */ + public function mount(iterable $data = []): void + { + if ($data !== []) { + $this->data = $data; + } + } + + public function getDataType(): ?string + { + return $this->dataType ??= $this->inferDataType(); + } + + /** + * @return array + */ + #[ExposeInTemplate('columns')] + public function getColumns(): array + { + uasort($this->columns, static fn (Column $a, Column $b): int => $b->priority <=> $a->priority); + + return $this->columns; + } + + /** + * @param callable(mixed): string $renderer + */ + public function addColumn(string $identifier, string $label, callable $renderer, int $priority = 0): self + { + $this->columns[$identifier] = new Column($identifier, $label, $renderer, $priority); + + return $this; + } + + public function removeColumn(string $identifier): self + { + unset($this->columns[$identifier]); + + return $this; + } + + /** + * @return class-string|null + */ + private function inferDataType(): ?string + { + $firstItem = null; + foreach ($this->data as $item) { + $firstItem = $item; + break; + } + + if (!is_object($firstItem)) { + return null; + } + + $candidates = array_merge( + [get_class($firstItem)], + class_parents($firstItem), + class_implements($firstItem) + ); + + foreach ($this->data as $item) { + $candidates = array_filter($candidates, static fn ($candidate): bool => $item instanceof $candidate); + if (empty($candidates)) { + return null; + } + } + + /** @var class-string|null $inferredType */ + $inferredType = reset($candidates); + + return $inferredType; + } + + public function renderCell(Column $column, mixed $item): string + { + return (string) ($column->renderer)($item); + } +} diff --git a/src/bundle/Templating/Twig/Components/Table/Column.php b/src/bundle/Templating/Twig/Components/Table/Column.php new file mode 100644 index 0000000000..a91bfb619c --- /dev/null +++ b/src/bundle/Templating/Twig/Components/Table/Column.php @@ -0,0 +1,23 @@ +get('admin'); + + $configResolver = self::getServiceByClassName(ConfigResolverInterface::class); + self::assertInstanceOf(ChainConfigResolver::class, $configResolver); + + foreach ($configResolver->getAllResolvers() as $resolver) { + if ($resolver instanceof SiteAccessAware) { + $resolver->setSiteAccess($siteAccess); + } + } + } + + public function testTableComponentMounts(): void + { + $component = $this->mountTwigComponent( + name: 'ibexa.Table', + data: [ + 'data' => [], + ], + ); + + self::assertInstanceOf(Table::class, $component); + } + + public function testTableComponentRenders(): void + { + $rendered = $this->renderTwigComponent( + name: 'ibexa.Table', + data: [ + 'data' => [], + ], + ); + + self::assertStringContainsString('ibexa-table', $rendered->toString()); + } + + public function testTableComponentInfersDataType(): void + { + $component = $this->mountTwigComponent( + name: 'ibexa.Table', + data: [ + 'data' => [new \stdClass(), new \stdClass()], + ], + ); + + self::assertInstanceOf(Table::class, $component); + + $reflection = new \ReflectionProperty(Table::class, 'dataType'); + $reflection->setAccessible(true); + + self::assertSame(\stdClass::class, $reflection->getValue($component)); + } + + public function testTableComponentRendersEmptyState(): void + { + $rendered = $this->renderTwigComponent( + name: 'ibexa.Table', + data: [ + 'data' => [], + 'emptyStateTitle' => new TranslatableMessage('Custom Title', [], 'messages'), + 'emptyStateDescription' => new TranslatableMessage('Custom Description', [], 'messages'), + ], + ); + + $html = $rendered->toString(); + self::assertStringContainsString('ibexa-empty-state', $html); + self::assertStringContainsString('Custom Title', $html); + self::assertStringContainsString('Custom Description', $html); + } + + public function testTableComponentRespectsPreMountEvent(): void + { + $dispatcher = self::getServiceByClassName(EventDispatcherInterface::class); + $listener = static function (PreMountEvent $event): void { + if (!$event->getComponent() instanceof Table) { + return; + } + + $data = $event->getData(); + $data['emptyStateTitle'] = new TranslatableMessage('Overridden Title', [], 'messages'); + $data['emptyStateDescription'] = new TranslatableMessage('Overridden Description', [], 'messages'); + $event->setData($data); + }; + $dispatcher->addListener(PreMountEvent::class, $listener); + + try { + $rendered = $this->renderTwigComponent( + name: 'ibexa.Table', + data: [ + 'data' => [], + 'emptyStateTitle' => new TranslatableMessage('Original Title', [], 'ibexa_search'), + 'emptyStateDescription' => new TranslatableMessage('Original Description', [], 'ibexa_search'), + ], + ); + + $html = $rendered->toString(); + self::assertStringContainsString('Overridden Title', $html); + self::assertStringNotContainsString('Original Title', $html); + self::assertStringContainsString('Overridden Description', $html); + self::assertStringNotContainsString('Original Description', $html); + } finally { + $dispatcher->removeListener(PreMountEvent::class, $listener); + } + } +} From 6bda7ece04111fef6c213ea8c57c03e924551d4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Mon, 9 Feb 2026 08:32:38 +0100 Subject: [PATCH 2/3] Fixed tests --- .../Templating/Twig/Components/TableTest.php | 37 ++++++++----------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/tests/integration/Templating/Twig/Components/TableTest.php b/tests/integration/Templating/Twig/Components/TableTest.php index 43e2f35d72..952f4630b5 100644 --- a/tests/integration/Templating/Twig/Components/TableTest.php +++ b/tests/integration/Templating/Twig/Components/TableTest.php @@ -76,10 +76,7 @@ public function testTableComponentInfersDataType(): void self::assertInstanceOf(Table::class, $component); - $reflection = new \ReflectionProperty(Table::class, 'dataType'); - $reflection->setAccessible(true); - - self::assertSame(\stdClass::class, $reflection->getValue($component)); + self::assertSame(\stdClass::class, $component->getDataType()); } public function testTableComponentRendersEmptyState(): void @@ -114,23 +111,19 @@ public function testTableComponentRespectsPreMountEvent(): void }; $dispatcher->addListener(PreMountEvent::class, $listener); - try { - $rendered = $this->renderTwigComponent( - name: 'ibexa.Table', - data: [ - 'data' => [], - 'emptyStateTitle' => new TranslatableMessage('Original Title', [], 'ibexa_search'), - 'emptyStateDescription' => new TranslatableMessage('Original Description', [], 'ibexa_search'), - ], - ); - - $html = $rendered->toString(); - self::assertStringContainsString('Overridden Title', $html); - self::assertStringNotContainsString('Original Title', $html); - self::assertStringContainsString('Overridden Description', $html); - self::assertStringNotContainsString('Original Description', $html); - } finally { - $dispatcher->removeListener(PreMountEvent::class, $listener); - } + $rendered = $this->renderTwigComponent( + name: 'ibexa.Table', + data: [ + 'data' => [], + 'emptyStateTitle' => new TranslatableMessage('Original Title', [], 'ibexa_search'), + 'emptyStateDescription' => new TranslatableMessage('Original Description', [], 'ibexa_search'), + ], + ); + + $html = $rendered->toString(); + self::assertStringContainsString('Overridden Title', $html); + self::assertStringNotContainsString('Original Title', $html); + self::assertStringContainsString('Overridden Description', $html); + self::assertStringNotContainsString('Original Description', $html); } } From 8dff713c24f306ea2949cc34107df71dd7c8f4f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Niedzielski?= Date: Mon, 9 Feb 2026 11:19:41 +0100 Subject: [PATCH 3/3] Fixed missing translations --- .../translations/ibexa_admin_ui.en.xliff | 5 +++++ .../themes/admin/components/table.html.twig | 2 ++ src/bundle/Templating/Twig/Components/Table.php | 16 ++++++++++++---- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/bundle/Resources/translations/ibexa_admin_ui.en.xliff b/src/bundle/Resources/translations/ibexa_admin_ui.en.xliff index b1f1754fe5..e14e11d586 100644 --- a/src/bundle/Resources/translations/ibexa_admin_ui.en.xliff +++ b/src/bundle/Resources/translations/ibexa_admin_ui.en.xliff @@ -81,6 +81,11 @@ Cancel key: side_panel.btn.cancel_label + + Empty table title + Empty table title + key: table.component.default.empty_title + Removed '%languageCode%' translation from '%name%'. Removed '%languageCode%' translation from '%name%'. diff --git a/src/bundle/Resources/views/themes/admin/components/table.html.twig b/src/bundle/Resources/views/themes/admin/components/table.html.twig index ba6c38d789..f7085bbaf8 100644 --- a/src/bundle/Resources/views/themes/admin/components/table.html.twig +++ b/src/bundle/Resources/views/themes/admin/components/table.html.twig @@ -1,3 +1,5 @@ +{% trans_default_domain 'ibexa_admin_ui' %} +
diff --git a/src/bundle/Templating/Twig/Components/Table.php b/src/bundle/Templating/Twig/Components/Table.php index bd665dc231..156e2a8287 100644 --- a/src/bundle/Templating/Twig/Components/Table.php +++ b/src/bundle/Templating/Twig/Components/Table.php @@ -9,6 +9,8 @@ namespace Ibexa\Bundle\AdminUi\Templating\Twig\Components; use Ibexa\Bundle\AdminUi\Templating\Twig\Components\Table\Column; +use JMS\TranslationBundle\Model\Message; +use JMS\TranslationBundle\Translation\TranslationContainerInterface; use Symfony\Component\Translation\TranslatableMessage; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; @@ -17,15 +19,13 @@ name: 'ibexa.Table', template: '@ibexadesign/components/table.html.twig', )] -final class Table +final class Table implements TranslationContainerInterface { /** * @var iterable */ public iterable $data = []; - public string $type = 'default'; - /** @var class-string|null */ private ?string $dataType = null; @@ -45,7 +45,7 @@ final class Table public function __construct() { - $this->emptyStateTitle = new TranslatableMessage('search.no_results.title', [], 'ibexa_admin_ui'); + $this->emptyStateTitle = new TranslatableMessage('table.component.default.empty_title', [], 'ibexa_admin_ui'); } /** @@ -129,4 +129,12 @@ public function renderCell(Column $column, mixed $item): string { return (string) ($column->renderer)($item); } + + public static function getTranslationMessages(): array + { + return [ + Message::create('table.component.default.empty_title', 'ibexa_admin_ui') + ->setDesc('Empty table title'), + ]; + } }