Skip to content
Open
Show file tree
Hide file tree
Changes from all 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,47 +1,64 @@
{% trans_default_domain 'ibexa_admin_ui' %}

{%~ 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">
<table class="ibexa-table table ibexa-table--last-column-sticky">
{%~ 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' }}">
{%~ for column in columns %}
<th class="{{ [
'ibexa-table__header-cell',
loop.last ? 'ibexa-table__header-cell--no-min-width',
column.options.header_class ?? '',
]|filter(class => class)|join(' ') }}">
<span class="ibexa-table__header-cell-content">
{{ this.renderLabel(column)|raw }}
</span>
</th>
{% endfor %}
{%~ endfor %}
</tr>
</thead>
<tbody class="ibexa-table__body">
{% for item in data %}
{%~ 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' }}">
{%~ for column in columns %}
<td class="{{ [
'ibexa-table__cell',
loop.last ? 'ibexa-table__cell--has-action-btns',
column.options.cell_class ?? '',
]|filter(class => class)|join(' ') }}">
{{ this.renderCell(column, item)|raw }}
</td>
{% endfor %}
{%~ endfor %}
</tr>
{% else %}
{% block empty_state_row %}
{%~ else %}
{%~ block empty_state_row %}
<tr class="ibexa-table__row">
<td class="ibexa-table__empty-table-cell">
{% block empty_state_content %}
{% block empty_state_image %}
<img class="ibexa-table__empty_table-image" src="{{ asset('/bundles/ibexaadminui/img/ibexa-empty-table.svg') }}" alt="">
{% endblock empty_state_image %}
{% block empty_state_text %}
<div class="ibexa-table__empty-table-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 %}
</div>
{% endblock empty_state_text %}
{% endblock empty_state_content %}
{%~ block empty_state_content %}
{%~ block empty_state_image %}
<img class="ibexa-table__empty_table-image" src="{{ asset('/bundles/ibexaadminui/img/ibexa-empty-table.svg') }}" alt="">
{%~ endblock empty_state_image %}
{%~ block empty_state_text %}
<div class="ibexa-table__empty-table-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 %}
</div>
{%~ endblock empty_state_text %}
{%~ endblock empty_state_content %}
</td>
</tr>
{% endblock empty_state_row %}
{% endfor %}
{%~ endblock empty_state_row %}
{%~ endfor %}
</tbody>
</table>
{%~ block after_table %}{% endblock %}
</div>
66 changes: 62 additions & 4 deletions src/bundle/Templating/Twig/Components/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,45 @@
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',
)]
final class Table
{
private const string BASE_CLASS = 'ibexa-table table';

/**
* 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; pass '' to render the bare base classes.
* The "ibexa-table table" base is always applied.
*/
public string $class = 'ibexa-table--last-column-sticky';

/** @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 +64,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 +85,24 @@ public function getData(): iterable
return $this->data;
}

/**
* 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<object>|null
*/
public function getFullData(): ?iterable
{
return $this->fullData;
}

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

/**
* @return class-string|null
*/
Expand Down Expand Up @@ -84,15 +135,22 @@ private function orderColumns(): array
/**
* @phpstan-param callable(Column): string $label
* @phpstan-param callable(mixed, Column): string $renderer
* @phpstan-param ColumnOptions $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
8 changes: 8 additions & 0 deletions src/bundle/Templating/Twig/Components/Table/Column.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,25 @@

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
* @phpstan-param ColumnOptions $options presentation hints consumed by the table template:
* "header_class" (extra CSS classes for the column's <th>),
* "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,
/** @var ColumnOptions */
public array $options = [],
) {
}
}
124 changes: 124 additions & 0 deletions tests/bundle/Templating/Twig/Components/TableTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?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
{
/**
* @dataProvider provideTableClass
*/
public function testTableClassAppliesModifierToBaseClasses(
?string $classProp,
string $expectedTableClass
): void {
$table = new Table();
if ($classProp !== null) {
$table->class = $classProp;
}

self::assertSame($expectedTableClass, $table->getTableClass());
}

/**
* @return iterable<string, array{?string, string}>
*/
public static function provideTableClass(): iterable
{
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 testFullDataIsNullWhenMountSiteDoesNotProvideIt(): void
{
$table = new Table();
$table->mount([new \stdClass()]);

self::assertNull($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();

self::assertNull($table->type);
self::assertNull($table->variant);
self::assertNull($table->headline);
}

/**
* @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();
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);
}

/**
* @return iterable<string, array{?array{header_class?: string, cell_class?: string}, array{header_class?: string, cell_class?: string}}>
*/
public static function provideColumnOptions(): iterable
{
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,
[],
];
}
}
Loading
Loading