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
4 changes: 4 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
83 changes: 83 additions & 0 deletions docs/Layout/errorPage.md
Original file line number Diff line number Diff line change
@@ -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 `<!DOCTYPE html>` 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 `<html lang>` | `'en'` |
| `appName` | `string` | Application name used in the `<title>` 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)).
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
70 changes: 70 additions & 0 deletions src/Component/Layout/ErrorPage.php
Original file line number Diff line number Diff line change
@@ -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');
}
}
22 changes: 22 additions & 0 deletions templates/layout/error_page.html.twig
Original file line number Diff line number Diff line change
@@ -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 }}</title>
<link rel="stylesheet" href="{{ asset(stylesheet) }}">
</head>
<body>
<div class="bg"><img src="{{ asset(symbol) }}" alt=""></div>
<div id="error">
<div class="error">
<div class="code-error"><h1>{{ statusCode }}</h1></div>
<h2>{{ title }}</h2>
<p>{{ message }}</p>
{% if backUrl %}<a href="{{ backUrl }}">{{ backLabel }}</a>{% endif %}
{% if details %}<small>{{ details }}</small>{% endif %}
<img class="logo" src="{{ asset(logo) }}" alt="{{ appName }}">
</div>
</div>
</body>
</html>
170 changes: 170 additions & 0 deletions tests/Component/Layout/ErrorPageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?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\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']);
}
}
Loading