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/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 new file mode 100644 index 0000000000..f7085bbaf8 --- /dev/null +++ b/src/bundle/Resources/views/themes/admin/components/table.html.twig @@ -0,0 +1,47 @@ +{% trans_default_domain 'ibexa_admin_ui' %} + +
+ + + + {% 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..156e2a8287 --- /dev/null +++ b/src/bundle/Templating/Twig/Components/Table.php @@ -0,0 +1,140 @@ + + */ + public iterable $data = []; + + /** @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('table.component.default.empty_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); + } + + public static function getTranslationMessages(): array + { + return [ + Message::create('table.component.default.empty_title', 'ibexa_admin_ui') + ->setDesc('Empty table title'), + ]; + } +} 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); + + self::assertSame(\stdClass::class, $component->getDataType()); + } + + 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); + + $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); + } +}