diff --git a/docs/05-UI-COMPONENT-MODEL.md b/docs/05-UI-COMPONENT-MODEL.md
index 3477095..63fa25f 100644
--- a/docs/05-UI-COMPONENT-MODEL.md
+++ b/docs/05-UI-COMPONENT-MODEL.md
@@ -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 `
`, `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 `
`,
+`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
diff --git a/docs/16-IMPLEMENTATION-BACKLOG.md b/docs/16-IMPLEMENTATION-BACKLOG.md
index 073d2e6..4afc32e 100644
--- a/docs/16-IMPLEMENTATION-BACKLOG.md
+++ b/docs/16-IMPLEMENTATION-BACKLOG.md
@@ -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
diff --git a/src/UI/Catalog/BaseUiCatalog.php b/src/UI/Catalog/BaseUiCatalog.php
index 13f0e4e..a185483 100644
--- a/src/UI/Catalog/BaseUiCatalog.php
+++ b/src/UI/Catalog/BaseUiCatalog.php
@@ -20,6 +20,7 @@
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;
@@ -27,6 +28,9 @@
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
@@ -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),
diff --git a/src/UI/Component/DataTable.php b/src/UI/Component/DataTable.php
index 4cffaef..b12c1ca 100644
--- a/src/UI/Component/DataTable.php
+++ b/src/UI/Component/DataTable.php
@@ -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 */
+ 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,
+ public ?TableQueryControls $queryControls = null,
+ public ?Pagination $pagination = null,
) {
if ($columns === []) {
throw new \InvalidArgumentException('Data table requires at least one column.');
@@ -39,9 +45,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 +59,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 +68,11 @@ public function __construct(
));
}
}
+
+ $normalizedRows[] = $tableRow;
}
+
+ $this->tableRows = $normalizedRows;
}
public static function componentType(): string
@@ -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 */
@@ -82,9 +107,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/Component/TableQueryControls.php b/src/UI/Component/TableQueryControls.php
new file mode 100644
index 0000000..3707122
--- /dev/null
+++ b/src/UI/Component/TableQueryControls.php
@@ -0,0 +1,92 @@
+ $filters
+ * @param array $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 */
+ public function filters(): array
+ {
+ return $this->filters;
+ }
+
+ /** @return array */
+ 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;
+ }
+}
diff --git a/src/UI/Model/SortDirection.php b/src/UI/Model/SortDirection.php
new file mode 100644
index 0000000..d0f7d71
--- /dev/null
+++ b/src/UI/Model/SortDirection.php
@@ -0,0 +1,11 @@
+ $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.');
+ }
+ }
+}
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/BaseTheme.php b/src/UI/Theme/Base/BaseTheme.php
index 2577fa5..ecbada3 100644
--- a/src/UI/Theme/Base/BaseTheme.php
+++ b/src/UI/Theme/Base/BaseTheme.php
@@ -21,6 +21,7 @@
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\Rendering\RendererRegistry;
use SyntaxDevTeam\MiniPortal\UI\PageDefinition;
use SyntaxDevTeam\MiniPortal\UI\Theme\Base\Renderer\AlertRenderer;
@@ -39,6 +40,7 @@
use SyntaxDevTeam\MiniPortal\UI\Theme\Base\Renderer\PaginationRenderer;
use SyntaxDevTeam\MiniPortal\UI\Theme\Base\Renderer\PermissionDeniedStateRenderer;
use SyntaxDevTeam\MiniPortal\UI\Theme\Base\Renderer\DegradedStateRenderer;
+use SyntaxDevTeam\MiniPortal\UI\Theme\Base\Renderer\TableQueryControlsRenderer;
final class BaseTheme implements Theme
{
@@ -76,6 +78,7 @@ public function renderers(): RendererRegistry
$registry->register(Pagination::class, new PaginationRenderer());
$registry->register(PermissionDeniedState::class, new PermissionDeniedStateRenderer());
$registry->register(DegradedState::class, new DegradedStateRenderer());
+ $registry->register(TableQueryControls::class, new TableQueryControlsRenderer());
return $registry;
}
diff --git a/src/UI/Theme/Base/Renderer/DataTableRenderer.php b/src/UI/Theme/Base/Renderer/DataTableRenderer.php
index df605bb..e2c4029 100644
--- a/src/UI/Theme/Base/Renderer/DataTableRenderer.php
+++ b/src/UI/Theme/Base/Renderer/DataTableRenderer.php
@@ -20,13 +20,15 @@ public function render(Component $component, RendererRegistry $registry): string
}
$identity = Html::identityAttribute(new ComponentRendererIdentity($component));
- if ($component->rows() === []) {
+ $controls = $component->queryControls === null ? '' : $registry->render($component->queryControls);
+ $pagination = $component->pagination === null ? '' : $registry->render($component->pagination);
+ if ($component->tableRows() === []) {
if ($component->emptyState === null) {
throw new \LogicException('Empty data table has no empty state.');
}
return '