Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
{% trans_default_domain 'ibexa_admin_ui' %}

<div class="ibexa-scrollable-wrapper">
<table class="ibexa-table table ibexa-table--last-column-sticky">
{% block header %}{% if headline is not null %}<div class="ibexa-table-header">
<div class="ibexa-table-header__headline">{{ headline }}</div>
{% block header_actions %}{% endblock %}
</div>
{% endif %}{% endblock %}<div class="ibexa-scrollable-wrapper">{% block before_table %}{% endblock %}

<table class="{{ table_class }}">
<thead>
<tr class="ibexa-table__row ibexa-table__row--header">
{% for column in columns %}
<th class="ibexa-table__header-cell {{ loop.last ? 'ibexa-table__header-cell--no-min-width' }}">
<th class="ibexa-table__header-cell {{ loop.last ? 'ibexa-table__header-cell--no-min-width' }}{% if column.options.header_class is defined %} {{ column.options.header_class }}{% endif %}">
<span class="ibexa-table__header-cell-content">
{{ this.renderLabel(column)|raw }}
</span>
Expand All @@ -17,7 +22,7 @@
{% for item in data %}
<tr class="ibexa-table__row">
{% for column in columns %}
<td class="ibexa-table__cell {{ loop.last ? 'ibexa-table__cell--has-action-btns' }}">
<td class="ibexa-table__cell {{ loop.last ? 'ibexa-table__cell--has-action-btns' }}{% if column.options.cell_class is defined %} {{ column.options.cell_class }}{% endif %}">
{{ this.renderCell(column, item)|raw }}
</td>
{% endfor %}
Expand All @@ -43,5 +48,6 @@
{% endblock empty_state_row %}
{% endfor %}
</tbody>
</table>
</table>{% block after_table %}{% endblock %}

</div>
61 changes: 57 additions & 4 deletions src/bundle/Templating/Twig/Components/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <table> element; null keeps the default
* "ibexa-table--last-column-sticky". The "ibexa-table table" base is always applied.
*/
public ?string $class = null;
Comment thread
bnowak marked this conversation as resolved.
Outdated

/** @var iterable<object> */
#[ExposeInTemplate]
private iterable $data = [];

/** @var iterable<object>|null */
private ?iterable $fullData = null;

/** @var class-string|null */
Comment thread
bnowak marked this conversation as resolved.
private ?string $dataType = null;

Expand All @@ -35,13 +62,17 @@ final class Table
private ?array $orderedColumns = null;

/**
* @param iterable<object> $data
* @param iterable<object> $data rows rendered by the table (e.g. the current pagination page)
* @param iterable<object>|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;
}

/**
Expand All @@ -52,6 +83,20 @@ public function getData(): iterable
return $this->data;
}

/**
* @return iterable<object>
*/
public function getFullData(): iterable
{
return $this->fullData ?? $this->data;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that a secure fallback? If we don't have fullData (as it's optional), it could be misleading for callers that they want fullData but are receiving data silently instead.

I'd recommend to just return $this->fullData as ?iterable type and and caller side it can decide if is fallbacking to $this->data or not (that would be its explicit decision).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: f54a10a

}

#[ExposeInTemplate('table_class')]
public function getTableClass(): string
{
return trim(self::BASE_CLASS . ' ' . ($this->class ?? self::DEFAULT_MODIFIER_CLASS));
}

/**
* @return class-string|null
*/
Expand Down Expand Up @@ -84,15 +129,23 @@ private function orderColumns(): array
/**
* @phpstan-param callable(Column): string $label
* @phpstan-param callable(mixed, Column): string $renderer
*
* @param array<string, string> $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;
Expand Down
5 changes: 5 additions & 0 deletions src/bundle/Templating/Twig/Components/Table/Column.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,17 @@
/**
* @phpstan-param Closure(Column): string $label
* @phpstan-param Closure(mixed, Column): string $renderer
*
* @param array<string, string> $options presentation hints consumed by the table template;
* supported keys: "header_class" (extra CSS classes for the column's <th>),
Comment thread
bnowak marked this conversation as resolved.
Outdated
* "cell_class" (extra CSS classes for the column's <td>)
*/
public function __construct(
public string $identifier,
public Closure $label,
public Closure $renderer,
public int $priority = 0,
public array $options = [],
) {
}
}
97 changes: 97 additions & 0 deletions tests/bundle/Templating/Twig/Components/TableTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Tests\Bundle\TwigComponents\Templating\Twig\Components;

use Ibexa\Bundle\TwigComponents\Templating\Twig\Components\Table;
use PHPUnit\Framework\TestCase;

final class TableTest extends TestCase
{
public function testTableClassDefaultsToLastColumnSticky(): void
Comment thread
konradoboza marked this conversation as resolved.
Outdated
{
$table = new Table();
$table->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);
}
}
75 changes: 75 additions & 0 deletions tests/integration/FormAwareTwigComponentsIbexaTestKernel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Tests\Integration\TwigComponents;

use Ibexa\Bundle\DesignEngine\IbexaDesignEngineBundle;
use Ibexa\Bundle\TwigComponents\IbexaTwigComponentsBundle;
use Ibexa\Contracts\Core\SiteAccess\ConfigResolverInterface;
use Ibexa\Contracts\Test\Core\IbexaTestKernel;
use Ibexa\Core\MVC\Symfony\SiteAccess\SiteAccessServiceInterface;
use Symfony\Bridge\Twig\Extension\FormExtension;
use Symfony\Bridge\Twig\Form\TwigRendererEngine;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormRenderer;
use Symfony\UX\TwigComponent\TwigComponentBundle;

/**
* Variant of {@see TwigComponentsIbexaTestKernel} with Symfony Forms enabled, for tests
* covering form widgets rendered inside table column closures.
*/
final class FormAwareTwigComponentsIbexaTestKernel extends IbexaTestKernel
{
public function registerBundles(): iterable
{
yield from parent::registerBundles();
yield new TwigComponentBundle();
yield new IbexaDesignEngineBundle();
yield new IbexaTwigComponentsBundle();
}

protected static function getExposedServicesByClass(): iterable
{
yield SiteAccessServiceInterface::class;
yield ConfigResolverInterface::class;
yield FormFactoryInterface::class;
}

public function registerContainerConfiguration(LoaderInterface $loader): void
{
parent::registerContainerConfiguration($loader);
$loader->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');
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Tests\Integration\TwigComponents\Templating\Twig\Components\Fixtures;

final readonly class VersionRow
{
public function __construct(
public int $versionNo,
public bool $canDelete,
) {
}
}
Loading
Loading