diff --git a/config/services.yaml b/config/services.yaml index 58a444d..e86596c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -59,3 +59,7 @@ services: Enabel\Ux\Component\Widget\Widget: tags: - { name: 'twig.component', key: 'Enabel:Ux:Widget', template: '@EnabelUx/widget/widget.html.twig', expose_public_props: true } + + Enabel\Ux\Component\Layout\ErrorPage: + tags: + - { name: 'twig.component', key: 'Enabel:Ux:ErrorPage', template: '@EnabelUx/layout/error_page.html.twig', expose_public_props: true } diff --git a/docs/Layout/errorPage.md b/docs/Layout/errorPage.md new file mode 100644 index 0000000..32253f3 --- /dev/null +++ b/docs/Layout/errorPage.md @@ -0,0 +1,83 @@ +# ErrorPage Component + +## Description + +A full-page error layout for Symfony's TwigBundle exception templates (`error.html.twig`, `error404.html.twig`, `error500.html.twig`, etc.). Replaces the abandoned `@EnabelLayout/error/base.html.twig` from `enabel/layout-bundle`. + +The component renders the full `` page (it intentionally does **not** extend `base.html.twig` — error pages must keep working even when the asset pipeline is broken) and emits the markup expected by the theme stylesheet shipped at `@enabel/enabel-bootstrap-theme/dist/css/error.min.css`. + +## Pre-requisite + +The CSS that styles the error page is shipped by the Enabel Bootstrap Theme. Add it to your importmap: + +```bash +symfony console importmap:require "@enabel/enabel-bootstrap-theme/dist/css/error.min.css" +``` + +If you serve the theme through a different path, override the `stylesheet` parameter (see below). + +## Parameters + +| Parameter | Type | Description | Default | +|:-------------|:----------|:-------------------------------------------------------------------------|:-------------------------------------------------------------------------| +| `statusCode` | `int` | HTTP status code displayed as the big number (**required**) | — | +| `title` | `string` | Heading shown below the status code (**required**) | — | +| `message` | `string` | Body paragraph explaining the error (**required**) | — | +| `details` | `?string` | Optional small print (trace IDs, technical details) | `null` | +| `backUrl` | `?string` | URL of the "back home" button. Button is hidden when `null` | `null` | +| `backLabel` | `string` | Label of the "back home" button | `'Back to homepage'` | +| `symbol` | `string` | Path to the watermark image (`.bg img`) | `'/images/enabel-symbol.png'` | +| `logo` | `string` | Path to the bottom logo (`.logo img`) | `'/images/enabel-logo-email.png'` | +| `locale` | `string` | Value used for `` | `'en'` | +| `appName` | `string` | Application name used in the `` tag and logo `alt` | `'Enabel'` | +| `stylesheet` | `string` | Path passed to `asset()` for the error CSS | `'vendor/@enabel/enabel-bootstrap-theme/dist/css/error.min.css'` | + +## Usage + +### Minimal 404 page + +```twig +{# templates/bundles/TwigBundle/Exception/error404.html.twig #} +{{ component('Enabel:Ux:ErrorPage', { + statusCode: 404, + title: 'app.error.404.title'|trans, + message: 'app.error.404.message'|trans, +}) }} +``` + +### With back button and localized labels + +```twig +{# templates/bundles/TwigBundle/Exception/error404.html.twig #} +{{ component('Enabel:Ux:ErrorPage', { + statusCode: 404, + title: 'app.error.404.title'|trans, + message: 'app.error.404.message'|trans, + backUrl: path('app_home'), + backLabel: 'app.error.btn.backhome'|trans, + locale: app.request.locale, + appName: 'app.name'|trans, +}) }} +``` + +### Generic error template with optional debug details + +```twig +{# templates/bundles/TwigBundle/Exception/error.html.twig #} +{{ component('Enabel:Ux:ErrorPage', { + statusCode: status_code, + title: status_text, + message: 'app.error.generic.message'|trans, + details: app.environment == 'dev' ? exception.message : null, + backUrl: path('app_home'), +}) }} +``` + +> [!TIP] +> On 5xx pages, leave `backUrl` unset — the trace ID then sits on its own line under the message instead of inline next to the button. + +## Why a full-page component? + +Symfony renders TwigBundle exception templates in a context where the regular `base.html.twig` cannot be trusted (the failing request might be the one that broke the asset pipeline). For this reason the component does not extend `base.html.twig` and only loads a single stylesheet via `asset()`. + +If you want to ship your own CSS for the error page, override the `stylesheet` parameter or override the template entirely with the standard bundle override mechanism (see the main [documentation](../index.md#how-to-override-templates)). diff --git a/docs/index.md b/docs/index.md index c0fc2ec..b06580d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,7 @@ https://github.com/Enabel/Ux ## Layouts - [Bootstrap](Layout/bootstrap.md) - A layout for rendering Bootstrap components with Enabel UX components & Enabel Bootstrap Theme +- [ErrorPage](Layout/errorPage.md) - A full-page error layout for Symfony's TwigBundle exception templates ## Install diff --git a/src/Component/Layout/ErrorPage.php b/src/Component/Layout/ErrorPage.php new file mode 100644 index 0000000..a1f5a95 --- /dev/null +++ b/src/Component/Layout/ErrorPage.php @@ -0,0 +1,70 @@ +<?php + +/* + * This file is part of the Enabel UX package. + * Copyright (c) Enabel <https://enabel.be/> + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\Ux\Component\Layout; + +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\UX\TwigComponent\Attribute\PreMount; + +class ErrorPage +{ + public int $statusCode; + public string $title; + public string $message; + public ?string $details; + public ?string $backUrl; + public string $backLabel; + public string $symbol; + public string $logo; + public string $locale; + public string $appName; + public string $stylesheet; + + /** + * @param array<string, mixed> $data + * + * @return array<string, mixed> + */ + #[PreMount] + public function preMount(array $data): array + { + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + + return $resolver->resolve($data) + $data; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setIgnoreUndefined(); + $resolver->setRequired(['statusCode', 'title', 'message']); + $resolver->setDefaults([ + 'details' => null, + 'backUrl' => null, + 'backLabel' => 'Back to homepage', + 'symbol' => '/images/enabel-symbol.png', + 'logo' => '/images/enabel-logo-email.png', + 'locale' => 'en', + 'appName' => 'Enabel', + 'stylesheet' => 'vendor/@enabel/enabel-bootstrap-theme/dist/css/error.min.css', + ]); + + $resolver->setAllowedTypes('statusCode', 'int'); + $resolver->setAllowedTypes('title', 'string'); + $resolver->setAllowedTypes('message', 'string'); + $resolver->setAllowedTypes('details', ['string', 'null']); + $resolver->setAllowedTypes('backUrl', ['string', 'null']); + $resolver->setAllowedTypes('backLabel', 'string'); + $resolver->setAllowedTypes('symbol', 'string'); + $resolver->setAllowedTypes('logo', 'string'); + $resolver->setAllowedTypes('locale', 'string'); + $resolver->setAllowedTypes('appName', 'string'); + $resolver->setAllowedTypes('stylesheet', 'string'); + } +} diff --git a/templates/layout/error_page.html.twig b/templates/layout/error_page.html.twig new file mode 100644 index 0000000..c31de0d --- /dev/null +++ b/templates/layout/error_page.html.twig @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html lang="{{ locale }}"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>{{ statusCode }} — {{ appName }} + + + +
+
+
+

{{ statusCode }}

+

{{ title }}

+

{{ message }}

+ {% if backUrl %}{{ backLabel }}{% endif %} + {% if details %}{{ details }}{% endif %} + +
+
+ + diff --git a/tests/Component/Layout/ErrorPageTest.php b/tests/Component/Layout/ErrorPageTest.php new file mode 100644 index 0000000..3566b50 --- /dev/null +++ b/tests/Component/Layout/ErrorPageTest.php @@ -0,0 +1,170 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\Ux\Tests\Component\Layout; + +use Enabel\Ux\Component\Layout\ErrorPage; +use PHPUnit\Framework\TestCase; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; +use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; + +class ErrorPageTest extends TestCase +{ + public function testComponentCanBeInstantiatedWithRequiredParameters(): void + { + $component = new ErrorPage(); + $data = $component->preMount([ + 'statusCode' => 404, + 'title' => 'Not Found', + 'message' => 'The page does not exist.', + ]); + + $component->statusCode = $data['statusCode']; + $component->title = $data['title']; + $component->message = $data['message']; + $component->details = $data['details']; + $component->backUrl = $data['backUrl']; + $component->backLabel = $data['backLabel']; + $component->symbol = $data['symbol']; + $component->logo = $data['logo']; + $component->locale = $data['locale']; + $component->appName = $data['appName']; + $component->stylesheet = $data['stylesheet']; + + $this->assertSame(404, $component->statusCode); + $this->assertSame('Not Found', $component->title); + $this->assertSame('The page does not exist.', $component->message); + $this->assertNull($component->details); + $this->assertNull($component->backUrl); + $this->assertSame('Back to homepage', $component->backLabel); + $this->assertSame('/images/enabel-symbol.png', $component->symbol); + $this->assertSame('/images/enabel-logo-email.png', $component->logo); + $this->assertSame('en', $component->locale); + $this->assertSame('Enabel', $component->appName); + $this->assertSame('vendor/@enabel/enabel-bootstrap-theme/dist/css/error.min.css', $component->stylesheet); + } + + public function testComponentCanBeInstantiatedWithCustomParameters(): void + { + $component = new ErrorPage(); + $data = $component->preMount([ + 'statusCode' => 500, + 'title' => 'Server Error', + 'message' => 'Something went wrong.', + 'details' => 'Trace ID: abc-123', + 'backUrl' => '/', + 'backLabel' => 'Return home', + 'symbol' => '/custom/symbol.svg', + 'logo' => '/custom/logo.svg', + 'locale' => 'fr', + 'appName' => 'Impala', + 'stylesheet' => 'css/custom-error.css', + ]); + + $this->assertSame(500, $data['statusCode']); + $this->assertSame('Server Error', $data['title']); + $this->assertSame('Something went wrong.', $data['message']); + $this->assertSame('Trace ID: abc-123', $data['details']); + $this->assertSame('/', $data['backUrl']); + $this->assertSame('Return home', $data['backLabel']); + $this->assertSame('/custom/symbol.svg', $data['symbol']); + $this->assertSame('/custom/logo.svg', $data['logo']); + $this->assertSame('fr', $data['locale']); + $this->assertSame('Impala', $data['appName']); + $this->assertSame('css/custom-error.css', $data['stylesheet']); + } + + public function testMissingStatusCodeThrows(): void + { + $this->expectException(MissingOptionsException::class); + + $component = new ErrorPage(); + $component->preMount(['title' => 'Not Found', 'message' => 'Missing']); + } + + public function testMissingTitleThrows(): void + { + $this->expectException(MissingOptionsException::class); + + $component = new ErrorPage(); + $component->preMount(['statusCode' => 404, 'message' => 'Missing']); + } + + public function testMissingMessageThrows(): void + { + $this->expectException(MissingOptionsException::class); + + $component = new ErrorPage(); + $component->preMount(['statusCode' => 404, 'title' => 'Not Found']); + } + + public function testInvalidStatusCodeTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new ErrorPage(); + $component->preMount([ + 'statusCode' => '404', + 'title' => 'Not Found', + 'message' => 'Missing', + ]); + } + + public function testInvalidTitleTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new ErrorPage(); + $component->preMount([ + 'statusCode' => 404, + 'title' => 404, + 'message' => 'Missing', + ]); + } + + public function testInvalidDetailsTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new ErrorPage(); + $component->preMount([ + 'statusCode' => 404, + 'title' => 'Not Found', + 'message' => 'Missing', + 'details' => 123, + ]); + } + + public function testInvalidBackUrlTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new ErrorPage(); + $component->preMount([ + 'statusCode' => 404, + 'title' => 'Not Found', + 'message' => 'Missing', + 'backUrl' => true, + ]); + } + + public function testPreMountPreservesAdditionalData(): void + { + $component = new ErrorPage(); + $data = $component->preMount([ + 'statusCode' => 404, + 'title' => 'Not Found', + 'message' => 'Missing', + 'custom_attribute' => 'value', + ]); + + $this->assertArrayHasKey('custom_attribute', $data); + $this->assertSame('value', $data['custom_attribute']); + } +}