From 64e83fb9e2f1fc0e3d87f04a86999a0defb84874 Mon Sep 17 00:00:00 2001 From: WieszczY Date: Fri, 25 Sep 2026 09:40:28 +0200 Subject: [PATCH 1/3] feat(ui): add stable table row identity --- src/UI/Component/DataTable.php | 37 +++++++++++++--- src/UI/Model/TableRow.php | 42 +++++++++++++++++++ .../Theme/Base/Renderer/DataTableRenderer.php | 9 ++-- tests/UI/UiDataStateTest.php | 27 ++++++++++++ 4 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 src/UI/Model/TableRow.php diff --git a/src/UI/Component/DataTable.php b/src/UI/Component/DataTable.php index 4cffaef..98f33f5 100644 --- a/src/UI/Component/DataTable.php +++ b/src/UI/Component/DataTable.php @@ -7,16 +7,20 @@ use SyntaxDevTeam\MiniPortal\UI\Contract\Component; use SyntaxDevTeam\MiniPortal\UI\Model\ComponentIdentity; use SyntaxDevTeam\MiniPortal\UI\Model\TableColumn; +use SyntaxDevTeam\MiniPortal\UI\Model\TableRow; final readonly class DataTable implements Component { + /** @var list */ + private array $tableRows; + /** * @param list $columns - * @param list> $rows + * @param list> $rows */ public function __construct( private array $columns, - private array $rows, + array $rows, public ?string $caption = null, public ?EmptyState $emptyState = null, private ?ComponentIdentity $componentIdentity = null, @@ -39,9 +43,13 @@ public function __construct( $columnIds[$column->id] = true; } + $normalizedRows = []; foreach ($rows as $rowIndex => $row) { + $tableRow = $row instanceof TableRow ? $row : TableRow::anonymous($row); + $cells = $tableRow->cells(); + foreach ($columnIds as $columnId => $_present) { - if (!array_key_exists($columnId, $row)) { + if (!array_key_exists($columnId, $cells)) { throw new \InvalidArgumentException(sprintf( 'Table row %d is missing column "%s".', $rowIndex, @@ -49,7 +57,7 @@ public function __construct( )); } } - foreach (array_keys($row) as $cellId) { + foreach (array_keys($cells) as $cellId) { if (!isset($columnIds[$cellId])) { throw new \InvalidArgumentException(sprintf( 'Table row %d contains unknown column "%s".', @@ -58,7 +66,11 @@ public function __construct( )); } } + + $normalizedRows[] = $tableRow; } + + $this->tableRows = $normalizedRows; } public static function componentType(): string @@ -82,9 +94,22 @@ public function columns(): array return $this->columns; } - /** @return list> */ + /** + * Backward-compatible scalar row view. + * + * @return list> + */ public function rows(): array { - return $this->rows; + return array_map( + static fn (TableRow $row): array => $row->cells(), + $this->tableRows, + ); + } + + /** @return list */ + public function tableRows(): array + { + return $this->tableRows; } } diff --git a/src/UI/Model/TableRow.php b/src/UI/Model/TableRow.php new file mode 100644 index 0000000..ed1ea26 --- /dev/null +++ b/src/UI/Model/TableRow.php @@ -0,0 +1,42 @@ + $cells */ + public function __construct( + public ?string $id, + private array $cells, + ) { + if ($id !== null && preg_match('/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/D', $id) !== 1) { + throw new \InvalidArgumentException('Table row ID must be a safe stable identifier.'); + } + if ($cells === []) { + throw new \InvalidArgumentException('Table row requires at least one cell.'); + } + } + + /** @param array $cells */ + public static function anonymous(array $cells): self + { + return new self(null, $cells); + } + + /** @return array */ + public function cells(): array + { + return $this->cells; + } + + public function cell(string $columnId): string|int|float|null + { + if (!array_key_exists($columnId, $this->cells)) { + throw new \OutOfBoundsException(sprintf('Table row has no cell for column "%s".', $columnId)); + } + + return $this->cells[$columnId]; + } +} diff --git a/src/UI/Theme/Base/Renderer/DataTableRenderer.php b/src/UI/Theme/Base/Renderer/DataTableRenderer.php index df605bb..9f1b26e 100644 --- a/src/UI/Theme/Base/Renderer/DataTableRenderer.php +++ b/src/UI/Theme/Base/Renderer/DataTableRenderer.php @@ -20,7 +20,7 @@ public function render(Component $component, RendererRegistry $registry): string } $identity = Html::identityAttribute(new ComponentRendererIdentity($component)); - if ($component->rows() === []) { + if ($component->tableRows() === []) { if ($component->emptyState === null) { throw new \LogicException('Empty data table has no empty state.'); } @@ -40,17 +40,18 @@ public function render(Component $component, RendererRegistry $registry): string } $body = ''; - foreach ($component->rows() as $row) { + foreach ($component->tableRows() as $row) { $cells = ''; foreach ($component->columns() as $column) { - $value = $row[$column->id]; + $value = $row->cell($column->id); $class = $column->numeric ? ' class="mp-data-table__numeric"' : ''; $content = $value === null ? '—' : Html::escape((string) $value); $cells .= '' . $content . ''; } - $body .= '' . $cells . ''; + $rowIdentity = $row->id === null ? '' : ' data-row-id="' . Html::escape($row->id) . '"'; + $body .= '' . $cells . ''; } return '
' diff --git a/tests/UI/UiDataStateTest.php b/tests/UI/UiDataStateTest.php index 4c1b277..da45e0e 100644 --- a/tests/UI/UiDataStateTest.php +++ b/tests/UI/UiDataStateTest.php @@ -12,6 +12,7 @@ use SyntaxDevTeam\MiniPortal\UI\Component\Pagination; use SyntaxDevTeam\MiniPortal\UI\Model\ComponentIdentity; use SyntaxDevTeam\MiniPortal\UI\Model\TableColumn; +use SyntaxDevTeam\MiniPortal\UI\Model\TableRow; use SyntaxDevTeam\MiniPortal\UI\Theme\Base\BaseTheme; final class UiDataStateTest extends TestCase @@ -42,6 +43,32 @@ public function testDataTableEscapesHeadersCaptionAndCells(): void self::assertStringNotContainsString('