diff --git a/README.md b/README.md index 35827c1..1bae21f 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ Projekt pozostaje świadomie lekki i server-driven: PHP generuje HTML, MySQL/Mar ## Status -**Faza: implementacja Service Platform / `1.0.0-alpha.2` (w toku).** +**Faza: implementacja UI Core / `1.0.0-alpha.3` (w toku).** -Core Kernel posiada już bootstrap, bezpieczny error boundary, data-only package discovery, routing, capability registry i izolację błędu modułu. Service Platform dostarcza baseline cache, bezpiecznego filesystemu, PDO Storage, plan-first migracji schematu per pakiet, typowanego klienta HTTP oraz kontraktu Jobs z pamięciowym providerem testowym. Nadal nie budujemy modułów domenowych takich jak Minecraft, VPS, PunisherX czy StableManagerX — najpierw platforma musi przejść kolejne kryteria roadmapy. +Core Kernel posiada bootstrap, bezpieczny error boundary, data-only package discovery, routing, capability registry i izolację błędu modułu. Service Platform ma bazowe kontrakty i providery dla cache, filesystemu, PDO Storage, migracji plan-first, HTTP, Jobs, Realtime i Audit. UI Core posiada semantyczne `PageDefinition`, renderer registry z dziedziczeniem theme, formularze, stany danych, podstawowy `DataTable`/`Pagination` oraz transport-neutralne renderowanie pełnej strony, regionu i pojedynczego komponentu. Nadal nie budujemy modułów domenowych takich jak Minecraft, VPS, PunisherX czy StableManagerX — najpierw platforma musi przejść kolejne kryteria roadmapy. ## Zasady nadrzędne diff --git a/docs/05-UI-COMPONENT-MODEL.md b/docs/05-UI-COMPONENT-MODEL.md index 7d722e0..3477095 100644 --- a/docs/05-UI-COMPONENT-MODEL.md +++ b/docs/05-UI-COMPONENT-MODEL.md @@ -242,10 +242,13 @@ Każdy data-driven component powinien jawnie obsługiwać: Nie należy dodawać ich ad hoc per module. -`LoadingState`, `EmptyState` i `ErrorState` są już częścią publicznego kontraktu. -Base Theme nadaje im odpowiednie role accessibility (`status`/`alert`), a -`ErrorState` może pokazać bezpieczny publiczny error ID bez ujawniania szczegółu -technicznego. Stany `degraded` i `permission denied` pozostają do dodania. +`LoadingState`, `EmptyState`, `ErrorState`, `PermissionDeniedState` i +`DegradedState` są częścią publicznego kontraktu. Base Theme nadaje im +odpowiednie role accessibility (`status`/`alert`), a `ErrorState` może pokazać +bezpieczny publiczny error ID bez ujawniania szczegółu technicznego. +`PermissionDeniedState` może opcjonalnie ujawnić wyłącznie bezpieczny identyfikator +wymaganego uprawnienia, natomiast `DegradedState` może zawierać nadal dostępne +komponenty zamiast sprowadzać częściową awarię do pustego ekranu. ## 9. Fragment rendering @@ -259,6 +262,14 @@ UI Renderer powinien umieć wyrenderować: Dzięki temu htmx/SSE korzystają z tego samego drzewa UI zamiast osobnych template'ów. +Bazowy kontrakt `UiRenderer` i implementacja `ThemeUiRenderer` obsługują już +pełną stronę, nazwany region oraz pojedynczy komponent. Renderowanie fragmentu +używa registry aktywnego theme wraz z całym łańcuchem dziedziczenia, więc np. +komponent odziedziczony z Base Theme może nadal zawierać zagnieżdżony override +aktywnego theme. Mechanika out-of-band update, wybór target/swap oraz error +replacement pozostają odpowiedzialnością przyszłego adaptera interakcji (htmx / +Realtime), a nie modułów domenowych. + ## 10. Component identity Komponenty dynamiczne potrzebują stabilnego logical ID, ale moduł nie powinien ręcznie składać losowych DOM ID. UI Core może generować DOM identity na podstawie component tree/request context. diff --git a/docs/16-IMPLEMENTATION-BACKLOG.md b/docs/16-IMPLEMENTATION-BACKLOG.md index 0f7d376..073d2e6 100644 --- a/docs/16-IMPLEMENTATION-BACKLOG.md +++ b/docs/16-IMPLEMENTATION-BACKLOG.md @@ -226,9 +226,10 @@ identyfikatory wierszy i akcje row/bulk. - permission denied, - degraded. -Baseline gotowy dla `LoadingState`, `EmptyState` i `ErrorState`, łącznie z -rendererami Base Theme, accessibility roles i reprezentacją w UI Catalog. -Pozostają stany permission denied oraz degraded. +Baseline gotowy dla `LoadingState`, `EmptyState`, `ErrorState`, +`PermissionDeniedState` i `DegradedState`, łącznie z rendererami Base Theme, +accessibility roles i reprezentacją w UI Catalog. `DegradedState` zachowuje +możliwość renderowania nadal dostępnej części drzewa UI. ### E6. Fragment rendering contract @@ -236,6 +237,10 @@ Pozostają stany permission denied oraz degraded. - named region, - single component. +Baseline gotowy: `UiRenderer` + `ThemeUiRenderer` renderują pełną stronę, +pojedynczy komponent i nazwany region przez registry aktywnego theme. Szczegóły +transportu, out-of-band updates i target/swap pozostają w Epic G. + ## Epic F — Theme Engine ### F1. Base Theme diff --git a/src/UI/Catalog/BaseUiCatalog.php b/src/UI/Catalog/BaseUiCatalog.php index 59dd5ce..13f0e4e 100644 --- a/src/UI/Catalog/BaseUiCatalog.php +++ b/src/UI/Catalog/BaseUiCatalog.php @@ -18,6 +18,8 @@ use SyntaxDevTeam\MiniPortal\UI\Component\ErrorState; use SyntaxDevTeam\MiniPortal\UI\Component\LoadingState; use SyntaxDevTeam\MiniPortal\UI\Component\Pagination; +use SyntaxDevTeam\MiniPortal\UI\Component\PermissionDeniedState; +use SyntaxDevTeam\MiniPortal\UI\Component\DegradedState; use SyntaxDevTeam\MiniPortal\UI\Model\AlertSeverity; use SyntaxDevTeam\MiniPortal\UI\Model\ComponentIdentity; use SyntaxDevTeam\MiniPortal\UI\Model\PageRegion; @@ -59,6 +61,10 @@ public function page(): PageDefinition ['name' => 'Worker', 'status' => 'idle', 'jobs' => 0], ], 'Usługi platformy'), 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), + ]), ], new ComponentIdentity('catalog.content'))]], ); } diff --git a/src/UI/Component/DegradedState.php b/src/UI/Component/DegradedState.php new file mode 100644 index 0000000..356fd5f --- /dev/null +++ b/src/UI/Component/DegradedState.php @@ -0,0 +1,38 @@ + $availableContent */ + public function __construct( + public string $title, + public string $description, + private array $availableContent = [], + private ?ComponentIdentity $componentIdentity = null, + ) { + if (trim($title) === '' || trim($description) === '') { + throw new \InvalidArgumentException('Degraded state requires a title and description.'); + } + } + + public static function componentType(): string + { + return 'degraded_state'; + } + + public function identity(): ?ComponentIdentity + { + return $this->componentIdentity; + } + + public function children(): array + { + return $this->availableContent; + } +} diff --git a/src/UI/Component/PermissionDeniedState.php b/src/UI/Component/PermissionDeniedState.php new file mode 100644 index 0000000..9227a3d --- /dev/null +++ b/src/UI/Component/PermissionDeniedState.php @@ -0,0 +1,41 @@ +componentIdentity; + } + + public function children(): array + { + return []; + } +} diff --git a/src/UI/Contract/UiRenderer.php b/src/UI/Contract/UiRenderer.php new file mode 100644 index 0000000..3a93fa4 --- /dev/null +++ b/src/UI/Contract/UiRenderer.php @@ -0,0 +1,14 @@ +renderers = $theme->renderers(); + } + + public function render(PageDefinition $page, string $language = 'pl'): string + { + return $this->theme->render($page, $language); + } + + public function renderComponent(Component $component): string + { + return $this->renderers->render($component); + } + + public function renderRegion(PageDefinition $page, string $region): string + { + return $this->renderers->renderMany($page->region($region)); + } +} diff --git a/src/UI/Theme/Base/BaseTheme.php b/src/UI/Theme/Base/BaseTheme.php index 34eb39a..2577fa5 100644 --- a/src/UI/Theme/Base/BaseTheme.php +++ b/src/UI/Theme/Base/BaseTheme.php @@ -19,6 +19,8 @@ use SyntaxDevTeam\MiniPortal\UI\Component\ErrorState; use SyntaxDevTeam\MiniPortal\UI\Component\LoadingState; use SyntaxDevTeam\MiniPortal\UI\Component\Pagination; +use SyntaxDevTeam\MiniPortal\UI\Component\PermissionDeniedState; +use SyntaxDevTeam\MiniPortal\UI\Component\DegradedState; use SyntaxDevTeam\MiniPortal\UI\Rendering\RendererRegistry; use SyntaxDevTeam\MiniPortal\UI\PageDefinition; use SyntaxDevTeam\MiniPortal\UI\Theme\Base\Renderer\AlertRenderer; @@ -35,6 +37,8 @@ use SyntaxDevTeam\MiniPortal\UI\Theme\Base\Renderer\ErrorStateRenderer; use SyntaxDevTeam\MiniPortal\UI\Theme\Base\Renderer\LoadingStateRenderer; use SyntaxDevTeam\MiniPortal\UI\Theme\Base\Renderer\PaginationRenderer; +use SyntaxDevTeam\MiniPortal\UI\Theme\Base\Renderer\PermissionDeniedStateRenderer; +use SyntaxDevTeam\MiniPortal\UI\Theme\Base\Renderer\DegradedStateRenderer; final class BaseTheme implements Theme { @@ -70,6 +74,8 @@ public function renderers(): RendererRegistry $registry->register(LoadingState::class, new LoadingStateRenderer()); $registry->register(DataTable::class, new DataTableRenderer()); $registry->register(Pagination::class, new PaginationRenderer()); + $registry->register(PermissionDeniedState::class, new PermissionDeniedStateRenderer()); + $registry->register(DegradedState::class, new DegradedStateRenderer()); return $registry; } diff --git a/src/UI/Theme/Base/Renderer/DegradedStateRenderer.php b/src/UI/Theme/Base/Renderer/DegradedStateRenderer.php new file mode 100644 index 0000000..db9eea2 --- /dev/null +++ b/src/UI/Theme/Base/Renderer/DegradedStateRenderer.php @@ -0,0 +1,32 @@ +children() === [] + ? '' + : '
' . $registry->renderMany($component->children()) . '
'; + + return '
' + . '

' . Html::escape($component->title) . '

' + . '

' . Html::escape($component->description) . '

' + . $content . '
'; + } +} diff --git a/src/UI/Theme/Base/Renderer/PermissionDeniedStateRenderer.php b/src/UI/Theme/Base/Renderer/PermissionDeniedStateRenderer.php new file mode 100644 index 0000000..8aea881 --- /dev/null +++ b/src/UI/Theme/Base/Renderer/PermissionDeniedStateRenderer.php @@ -0,0 +1,35 @@ +description === null + ? '' + : '

' . Html::escape($component->description) . '

'; + $permission = $component->requiredPermission === null + ? '' + : '

Wymagane uprawnienie: ' + . Html::escape($component->requiredPermission) . '

'; + + return ''; + } +} diff --git a/tests/UI/BaseThemeTest.php b/tests/UI/BaseThemeTest.php index 4a80959..791484d 100644 --- a/tests/UI/BaseThemeTest.php +++ b/tests/UI/BaseThemeTest.php @@ -21,6 +21,8 @@ use SyntaxDevTeam\MiniPortal\UI\Component\ErrorState; use SyntaxDevTeam\MiniPortal\UI\Component\LoadingState; use SyntaxDevTeam\MiniPortal\UI\Component\Pagination; +use SyntaxDevTeam\MiniPortal\UI\Component\PermissionDeniedState; +use SyntaxDevTeam\MiniPortal\UI\Component\DegradedState; use SyntaxDevTeam\MiniPortal\UI\Model\AlertSeverity; use SyntaxDevTeam\MiniPortal\UI\Model\ComponentIdentity; use SyntaxDevTeam\MiniPortal\UI\Model\PageRegion; @@ -53,6 +55,8 @@ public function testEveryCurrentPublicComponentHasBaseRenderer(): void LoadingState::class, DataTable::class, Pagination::class, + PermissionDeniedState::class, + DegradedState::class, ], $registered); } @@ -92,6 +96,8 @@ public function testCatalogExercisesEveryRegisteredComponent(): void self::assertStringContainsString('mp-error-state', $html); self::assertStringContainsString('mp-data-table', $html); self::assertStringContainsString('mp-pagination', $html); + self::assertStringContainsString('mp-permission-denied-state', $html); + self::assertStringContainsString('mp-degraded-state', $html); } public function testUnknownComponentFailsExplicitly(): void diff --git a/tests/UI/ThemeUiRendererTest.php b/tests/UI/ThemeUiRendererTest.php new file mode 100644 index 0000000..c92e411 --- /dev/null +++ b/tests/UI/ThemeUiRendererTest.php @@ -0,0 +1,81 @@ + [new Text('Treść')], + ]); + + $html = $renderer->render($page, 'pl-PL'); + + self::assertStringContainsString('class="mp-plasma admin-shell"', $html); + self::assertStringContainsString('href="/assets/theme.css"', $html); + self::assertStringContainsString('', $html); + } + + public function testSingleComponentUsesActiveThemeInheritanceChain(): void + { + $renderer = new ThemeUiRenderer(new PlasmaTheme()); + + $html = $renderer->renderComponent(new Stack([ + new Card([new Text('Nested & safe')], 'Karta'), + ])); + + self::assertStringContainsString('class="mp-stack"', $html); + self::assertStringContainsString('class="mp-card plasma-card"', $html); + self::assertStringContainsString('Nested & safe', $html); + } + + public function testNamedRegionRendersOnlyRequestedRegion(): void + { + $renderer = new ThemeUiRenderer(new PlasmaTheme()); + $page = new PageDefinition('fragments', 'Fragmenty', 'application', [ + PageRegion::CONTENT => [new Text('Treść główna')], + PageRegion::ASIDE => [new Card([new Text('Boczna')], 'Aside')], + ]); + + $html = $renderer->renderRegion($page, PageRegion::ASIDE); + + self::assertStringContainsString('plasma-card', $html); + self::assertStringContainsString('Boczna', $html); + self::assertStringNotContainsString('Treść główna', $html); + self::assertStringNotContainsString('', $html); + } + + public function testMissingValidRegionRendersEmptyFragment(): void + { + $renderer = new ThemeUiRenderer(new PlasmaTheme()); + $page = new PageDefinition('fragments', 'Fragmenty', 'application', [ + PageRegion::CONTENT => [new Text('Treść')], + ]); + + self::assertSame('', $renderer->renderRegion($page, PageRegion::ASIDE)); + } + + public function testInvalidRegionIdentifierIsRejectedByPageContract(): void + { + $renderer = new ThemeUiRenderer(new PlasmaTheme()); + $page = new PageDefinition('fragments', 'Fragmenty', 'application', [ + PageRegion::CONTENT => [new Text('Treść')], + ]); + + $this->expectException(\InvalidArgumentException::class); + $renderer->renderRegion($page, 'invalid region'); + } +} diff --git a/tests/UI/UiAvailabilityStateTest.php b/tests/UI/UiAvailabilityStateTest.php new file mode 100644 index 0000000..16ec444 --- /dev/null +++ b/tests/UI/UiAvailabilityStateTest.php @@ -0,0 +1,50 @@ +renderers()->render(new PermissionDeniedState( + 'Brak ', + 'Skontaktuj się z administratorem & spróbuj ponownie.', + 'module.files.read', + )); + + self::assertStringContainsString('role="alert"', $html); + self::assertStringContainsString('Brak <dostępu>', $html); + self::assertStringContainsString('administratorem & spróbuj', $html); + self::assertStringContainsString('module.files.read', $html); + } + + public function testPermissionDeniedStateRejectsUnsafePermissionIdentifier(): void + { + $this->expectException(\InvalidArgumentException::class); + new PermissionDeniedState(requiredPermission: '