Skip to content
Merged
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
25 changes: 17 additions & 8 deletions docs/05-UI-COMPONENT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,14 +205,23 @@ Tabela jest częstym źródłem duplikacji. Publiczny Table contract powinien ws

Na mobile Theme może zmienić tabelę w zestaw kart, jeśli zachowane są semantyka i dostęp do tych samych działań.

Pierwszy baseline `DataTable` posiada jawny zestaw `TableColumn`, waliduje
unikalność kolumn i zgodność każdego wiersza ze schematem, nie przyjmuje surowego
HTML w komórkach oraz wymaga jawnego `EmptyState`, gdy wynik jest pusty. Renderer
Base Theme używa semantycznego `<table>`, `scope="col"`, caption i bezpiecznego
escape wartości. `Pagination` opisuje bieżącą/łączną liczbę stron oraz bezpieczne
linki poprzednia/następna i sprawdza ich zgodność z granicami zbioru. Sort,
filtry, search, row identifiers oraz row/bulk actions pozostają kolejnym etapem
Table API.
Baseline `DataTable` posiada jawny zestaw `TableColumn`, waliduje unikalność
kolumn i zgodność każdego wiersza ze schematem, nie przyjmuje surowego HTML w
komórkach oraz wymaga jawnego `EmptyState`, gdy wynik jest pusty. `TableRow`
umożliwia nadanie stabilnego, bezpiecznego identyfikatora wiersza; dla zgodności
proste tablice skalarów nadal są przyjmowane jako anonimowe wiersze.

Server-driven query API obejmuje `TableQueryControls`: wyszukiwanie i filtry są
zwykłym formularzem GET, mogą zachowywać jawnie wskazane parametry zapytania, a
sortowanie jest deklarowane per `TableColumn` jako bezpieczny URL i opcjonalny
`SortDirection`. Renderer Base Theme generuje semantyczny `<table>`,
`scope="col"`, `aria-sort`, caption i centralny escape wartości. `Pagination`
może być częścią `DataTable` i opisuje bieżącą/łączną liczbę stron oraz
bezpieczne linki poprzednia/następna.

Źródło danych jako osobny kontrakt oraz row/bulk actions pozostają kolejnym
etapem. Mutujące akcje tabeli nie mogą być dodane jako przypadkowe linki;
kontrakt musi uwzględnić metodę żądania, CSRF i confirmation semantics.

## 7. Actions i Intents

Expand Down
10 changes: 6 additions & 4 deletions docs/16-IMPLEMENTATION-BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,12 @@ mutujących providerów pozostają do wykonania.
- pagination,
- row/bulk actions.

Baseline gotowy: `TableColumn`, ścisła walidacja schematu wierszy, bezpieczne
renderowanie skalarów, jawny empty state oraz serwerowa paginacja poprzednia /
następna. Następny slice obejmuje źródło danych, sort/filter/search,
identyfikatory wierszy i akcje row/bulk.
Baseline rozszerzony: `TableColumn`, `TableRow` ze stabilnym identity, ścisła
walidacja schematu wierszy, bezpieczne renderowanie skalarów, jawny empty state,
`TableQueryControls` dla server-driven search/filter, deklaratywne sortowanie z
`aria-sort` oraz paginacja poprzednia/następna. Następny slice obejmuje osobny
kontrakt źródła danych oraz row/bulk actions z poprawnym CSRF, metodą żądania i
confirmation semantics.

### E5. States

Expand Down
20 changes: 14 additions & 6 deletions src/UI/Catalog/BaseUiCatalog.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@
use SyntaxDevTeam\MiniPortal\UI\Component\Pagination;
use SyntaxDevTeam\MiniPortal\UI\Component\PermissionDeniedState;
use SyntaxDevTeam\MiniPortal\UI\Component\DegradedState;
use SyntaxDevTeam\MiniPortal\UI\Component\TableQueryControls;
use SyntaxDevTeam\MiniPortal\UI\Model\AlertSeverity;
use SyntaxDevTeam\MiniPortal\UI\Model\ComponentIdentity;
use SyntaxDevTeam\MiniPortal\UI\Model\PageRegion;
use SyntaxDevTeam\MiniPortal\UI\Model\TextTone;
use SyntaxDevTeam\MiniPortal\UI\Model\FormMethod;
use SyntaxDevTeam\MiniPortal\UI\Model\InputType;
use SyntaxDevTeam\MiniPortal\UI\Model\TableColumn;
use SyntaxDevTeam\MiniPortal\UI\Model\TableFilter;
use SyntaxDevTeam\MiniPortal\UI\Model\TableRow;
use SyntaxDevTeam\MiniPortal\UI\Model\SortDirection;
use SyntaxDevTeam\MiniPortal\UI\PageDefinition;

final class BaseUiCatalog
Expand All @@ -53,14 +57,18 @@ public function page(): PageDefinition
new EmptyState('Brak wyników', 'Zmień filtry lub wyszukiwaną frazę.'),
new ErrorState('Nie udało się pobrać danych', 'Spróbuj ponownie później.', 'catalog-error-01'),
new DataTable([
new TableColumn('name', 'Nazwa'),
new TableColumn('status', 'Status'),
new TableColumn('name', 'Nazwa', sortUrl: '/catalog?sort=name&direction=desc', sortDirection: SortDirection::Ascending),
new TableColumn('status', 'Status', sortUrl: '/catalog?sort=status&direction=asc'),
new TableColumn('jobs', 'Zadania', true),
], [
['name' => 'Core', 'status' => 'online', 'jobs' => 4],
['name' => 'Worker', 'status' => 'idle', 'jobs' => 0],
], 'Usługi platformy'),
new Pagination(2, 3, '/catalog?page=1', '/catalog?page=3'),
new TableRow('service:core', ['name' => 'Core', 'status' => 'online', 'jobs' => 4]),
new TableRow('service:worker', ['name' => 'Worker', 'status' => 'idle', 'jobs' => 0]),
], 'Usługi platformy', componentIdentity: new ComponentIdentity('catalog.services'), queryControls: new TableQueryControls(
'/catalog',
'core',
filters: [new TableFilter('status', 'Status', ['' => 'Wszystkie', 'online' => 'Online', 'idle' => 'Idle'], '')],
preservedParameters: ['sort' => 'name'],
), pagination: new Pagination(2, 3, '/catalog?page=1', '/catalog?page=3')),
new PermissionDeniedState('Brak dostępu', 'Nie masz uprawnienia do tej sekcji.', 'admin.system.read'),
new DegradedState('Dane częściowe', 'Jedno ze źródeł jest chwilowo niedostępne.', [
new Text('Pozostałe dane pozostają dostępne.', TextTone::Muted),
Expand Down
52 changes: 45 additions & 7 deletions src/UI/Component/DataTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,25 @@
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<TableRow> */
private array $tableRows;

/**
* @param list<TableColumn> $columns
* @param list<array<string, string|int|float|null>> $rows
* @param list<TableRow|array<string, string|int|float|null>> $rows
*/
public function __construct(
private array $columns,
private array $rows,
array $rows,
public ?string $caption = null,
public ?EmptyState $emptyState = null,
private ?ComponentIdentity $componentIdentity = null,
public ?TableQueryControls $queryControls = null,
public ?Pagination $pagination = null,
) {
if ($columns === []) {
throw new \InvalidArgumentException('Data table requires at least one column.');
Expand All @@ -39,17 +45,21 @@ 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,
$columnId,
));
}
}
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".',
Expand All @@ -58,7 +68,11 @@ public function __construct(
));
}
}

$normalizedRows[] = $tableRow;
}

$this->tableRows = $normalizedRows;
}

public static function componentType(): string
Expand All @@ -73,7 +87,18 @@ public function identity(): ?ComponentIdentity

public function children(): array
{
return $this->emptyState === null ? [] : [$this->emptyState];
$children = [];
if ($this->queryControls !== null) {
$children[] = $this->queryControls;
}
if ($this->emptyState !== null) {
$children[] = $this->emptyState;
}
if ($this->pagination !== null) {
$children[] = $this->pagination;
}

return $children;
}

/** @return list<TableColumn> */
Expand All @@ -82,9 +107,22 @@ public function columns(): array
return $this->columns;
}

/** @return list<array<string, string|int|float|null>> */
/**
* Backward-compatible scalar row view.
*
* @return list<array<string, string|int|float|null>>
*/
public function rows(): array
{
return $this->rows;
return array_map(
static fn (TableRow $row): array => $row->cells(),
$this->tableRows,
);
}

/** @return list<TableRow> */
public function tableRows(): array
{
return $this->tableRows;
}
}
92 changes: 92 additions & 0 deletions src/UI/Component/TableQueryControls.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

namespace SyntaxDevTeam\MiniPortal\UI\Component;

use SyntaxDevTeam\MiniPortal\UI\Contract\Component;
use SyntaxDevTeam\MiniPortal\UI\Model\ComponentIdentity;
use SyntaxDevTeam\MiniPortal\UI\Model\TableFilter;

final readonly class TableQueryControls implements Component
{
/**
* @param list<TableFilter> $filters
* @param array<string, string> $preservedParameters
*/
public function __construct(
public string $action,
public ?string $searchValue = null,
public ?string $searchParameter = 'q',
public string $searchLabel = 'Szukaj',
private array $filters = [],
private array $preservedParameters = [],
private ?ComponentIdentity $componentIdentity = null,
) {
if (!self::isSafeUrl($action)) {
throw new \InvalidArgumentException('Table query action must be relative or HTTPS.');
}
if ($searchParameter !== null && preg_match('/^[a-z][a-z0-9_-]{0,63}$/D', $searchParameter) !== 1) {
throw new \InvalidArgumentException('Table search parameter must be a safe query parameter name.');
}
if ($searchParameter === null && $searchValue !== null) {
throw new \InvalidArgumentException('Search value requires an enabled search parameter.');
}
if ($searchParameter !== null && trim($searchLabel) === '') {
throw new \InvalidArgumentException('Enabled table search requires a label.');
}

$names = [];
if ($searchParameter !== null) {
$names[$searchParameter] = true;
}
foreach ($filters as $filter) {
if (isset($names[$filter->name])) {
throw new \InvalidArgumentException(sprintf('Duplicate table query parameter "%s".', $filter->name));
}
$names[$filter->name] = true;
}
foreach ($preservedParameters as $name => $_value) {
if (preg_match('/^[a-z][a-z0-9_-]{0,63}$/D', $name) !== 1 || isset($names[$name])) {
throw new \InvalidArgumentException(sprintf('Invalid or duplicate preserved query parameter "%s".', $name));
}
$names[$name] = true;
}
if ($searchParameter === null && $filters === [] && $preservedParameters === []) {
throw new \InvalidArgumentException('Table query controls require search, filters or preserved parameters.');
}
}

public static function componentType(): string
{
return 'table_query_controls';
}

public function identity(): ?ComponentIdentity
{
return $this->componentIdentity;
}

public function children(): array
{
return [];
}

/** @return list<TableFilter> */
public function filters(): array
{
return $this->filters;
}

/** @return array<string, string> */
public function preservedParameters(): array
{
return $this->preservedParameters;
}

private static function isSafeUrl(string $url): bool
{
return str_starts_with($url, '/')
|| preg_match('/^https:\/\/[A-Za-z0-9.-]+(?::[0-9]+)?(?:\/|$)/D', $url) === 1;
}
}
11 changes: 11 additions & 0 deletions src/UI/Model/SortDirection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace SyntaxDevTeam\MiniPortal\UI\Model;

enum SortDirection: string
{
case Ascending = 'ascending';
case Descending = 'descending';
}
9 changes: 9 additions & 0 deletions src/UI/Model/TableColumn.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,18 @@ public function __construct(
public string $id,
public string $label,
public bool $numeric = false,
public ?string $sortUrl = null,
public ?SortDirection $sortDirection = null,
) {
if (preg_match('/^[a-z][a-z0-9_.-]{0,63}$/D', $id) !== 1 || trim($label) === '') {
throw new \InvalidArgumentException('Table column requires a valid ID and label.');
}
if ($sortUrl !== null && !str_starts_with($sortUrl, '/')
&& preg_match('/^https:\/\/[A-Za-z0-9.-]+(?::[0-9]+)?(?:\/|$)/D', $sortUrl) !== 1) {
throw new \InvalidArgumentException('Table sort URL must be relative or HTTPS.');
}
if ($sortDirection !== null && $sortUrl === null) {
throw new \InvalidArgumentException('Active table sort direction requires a sort URL.');
}
}
}
28 changes: 28 additions & 0 deletions src/UI/Model/TableFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace SyntaxDevTeam\MiniPortal\UI\Model;

final readonly class TableFilter
{
/** @param array<string, string> $options */
public function __construct(
public string $name,
public string $label,
public array $options,
public ?string $selected = null,
) {
if (preg_match('/^[a-z][a-z0-9_-]{0,63}$/D', $name) !== 1 || trim($label) === '' || $options === []) {
throw new \InvalidArgumentException('Table filter requires a valid name, label and options.');
}
foreach ($options as $optionLabel) {
if (trim($optionLabel) === '') {
throw new \InvalidArgumentException('Table filter option labels cannot be empty.');
}
}
if ($selected !== null && !array_key_exists($selected, $options)) {
throw new \InvalidArgumentException('Selected table filter value must exist in options.');
}
}
}
42 changes: 42 additions & 0 deletions src/UI/Model/TableRow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

declare(strict_types=1);

namespace SyntaxDevTeam\MiniPortal\UI\Model;

final readonly class TableRow
{
/** @param array<string, string|int|float|null> $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<string, string|int|float|null> $cells */
public static function anonymous(array $cells): self
{
return new self(null, $cells);
}

/** @return array<string, string|int|float|null> */
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];
}
}
Loading
Loading