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 '
' - . $registry->render($component->emptyState) . '
'; + . $controls . $registry->render($component->emptyState) . $pagination . ''; } $caption = $component->caption === null @@ -36,24 +38,35 @@ public function render(Component $component, RendererRegistry $registry): string $head = ''; foreach ($component->columns() as $column) { $class = $column->numeric ? ' class="mp-data-table__numeric"' : ''; - $head .= '
'; + $ariaSort = $column->sortDirection === null ? '' : ' aria-sort="' . $column->sortDirection->value . '"'; + $label = Html::escape($column->label); + if ($column->sortUrl !== null) { + $indicator = match ($column->sortDirection) { + \SyntaxDevTeam\MiniPortal\UI\Model\SortDirection::Ascending => ' ', + \SyntaxDevTeam\MiniPortal\UI\Model\SortDirection::Descending => ' ', + null => '', + }; + $label = '' . $label . $indicator . ''; + } + $head .= ''; } $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 '
' . Html::escape($column->label) . '' . $label . '
' - . $caption . '' . $head . '' . $body . '
'; + return '
' . $controls . '' + . $caption . '' . $head . '' . $body . '
' . $pagination . '
'; } } diff --git a/src/UI/Theme/Base/Renderer/TableQueryControlsRenderer.php b/src/UI/Theme/Base/Renderer/TableQueryControlsRenderer.php new file mode 100644 index 0000000..f8872f5 --- /dev/null +++ b/src/UI/Theme/Base/Renderer/TableQueryControlsRenderer.php @@ -0,0 +1,48 @@ +searchParameter !== null) { + $value = $component->searchValue === null ? '' : ' value="' . Html::escape($component->searchValue) . '"'; + $fields .= ''; + } + + foreach ($component->filters() as $filter) { + $options = ''; + foreach ($filter->options as $value => $label) { + $selected = $filter->selected === $value ? ' selected' : ''; + $options .= ''; + } + $fields .= ''; + } + + foreach ($component->preservedParameters() as $name => $value) { + $fields .= ''; + } + + return ''; + } +} diff --git a/tests/UI/BaseThemeTest.php b/tests/UI/BaseThemeTest.php index 791484d..eeec77e 100644 --- a/tests/UI/BaseThemeTest.php +++ b/tests/UI/BaseThemeTest.php @@ -23,6 +23,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; @@ -57,6 +58,7 @@ public function testEveryCurrentPublicComponentHasBaseRenderer(): void Pagination::class, PermissionDeniedState::class, DegradedState::class, + TableQueryControls::class, ], $registered); } @@ -98,6 +100,9 @@ public function testCatalogExercisesEveryRegisteredComponent(): void self::assertStringContainsString('mp-pagination', $html); self::assertStringContainsString('mp-permission-denied-state', $html); self::assertStringContainsString('mp-degraded-state', $html); + self::assertStringContainsString('mp-table-query', $html); + self::assertStringContainsString('aria-sort="ascending"', $html); + self::assertStringContainsString('data-row-id="service:core"', $html); } public function testUnknownComponentFailsExplicitly(): void diff --git a/tests/UI/UiDataStateTest.php b/tests/UI/UiDataStateTest.php index 4c1b277..1e20565 100644 --- a/tests/UI/UiDataStateTest.php +++ b/tests/UI/UiDataStateTest.php @@ -10,8 +10,12 @@ use SyntaxDevTeam\MiniPortal\UI\Component\ErrorState; use SyntaxDevTeam\MiniPortal\UI\Component\LoadingState; use SyntaxDevTeam\MiniPortal\UI\Component\Pagination; +use SyntaxDevTeam\MiniPortal\UI\Component\TableQueryControls; use SyntaxDevTeam\MiniPortal\UI\Model\ComponentIdentity; use SyntaxDevTeam\MiniPortal\UI\Model\TableColumn; +use SyntaxDevTeam\MiniPortal\UI\Model\TableRow; +use SyntaxDevTeam\MiniPortal\UI\Model\TableFilter; +use SyntaxDevTeam\MiniPortal\UI\Model\SortDirection; use SyntaxDevTeam\MiniPortal\UI\Theme\Base\BaseTheme; final class UiDataStateTest extends TestCase @@ -42,6 +46,32 @@ public function testDataTableEscapesHeadersCaptionAndCells(): void self::assertStringNotContainsString('