diff --git a/config/services.yaml b/config/services.yaml index 2c885ea..e1a88ed 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -44,6 +44,10 @@ services: tags: - { name: 'twig.component', key: 'Enabel:Ux:Tab', template: '@EnabelUx/navigation/tab.html.twig', expose_public_props: true } + Enabel\Ux\Component\Navigation\ImpersonateBanner: + tags: + - { name: 'twig.component', key: 'Enabel:Ux:ImpersonateBanner', template: '@EnabelUx/navigation/impersonate_banner.html.twig', expose_public_props: true } + Enabel\Ux\Component\Callout\Callout: tags: - { name: 'twig.component', key: 'Enabel:Ux:Callout', template: '@EnabelUx/callout/callout.html.twig', expose_public_props: true } diff --git a/docs/Navigation/impersonateBanner.md b/docs/Navigation/impersonateBanner.md new file mode 100644 index 0000000..0129d61 --- /dev/null +++ b/docs/Navigation/impersonateBanner.md @@ -0,0 +1,82 @@ +# ImpersonateBanner Component + +## Description + +A fixed-top warning banner that signals the current user is impersonating someone else, with a clear "Exit impersonation" link. Pairs with `Enabel:Ux:ImpersonateDropdown` (issue #15) which provides the entry point on the navbar side. + +The component renders an `28 px` band pinned to the top of the viewport (`position: fixed; z-index: 1031`), above any Bootstrap `.navbar.fixed-top`. It also ships the small CSS trick that makes the rest of the page lay out correctly when the banner is present: + +```css +.impersonate-banner ~ .navbar.fixed-top { top: 28px; } +body:has(.impersonate-banner) { --app-navbar-offset: 108px; } +``` + +Without that pair, the `min-vh-100` content below the navbar ends up either crammed under the banner or leaves a large empty gap. + +## Parameters + +| Parameter | Type | Description | Default | +|:------------|:---------|:-------------------------------------------------------------------------|:------------------------| +| `message` | `string` | Banner text — typically translated and parameterised with the user name (**required**) | — | +| `exitUrl` | `string` | Link target for the exit-impersonation control (**required**) | — | +| `exitLabel` | `string` | Label of the exit link | `'Exit impersonation'` | +| `icon` | `string` | Icon identifier passed to `ux_icon()` | `'fa6-solid:user-secret'` | + +`message` is rendered with `|raw` so the call site can include simple HTML formatting (e.g. `` around the impersonated user name). Make sure any user-provided substring is escaped before passing it in. + +## Usage + +### Wired with Symfony's impersonation security feature + +```twig +{# templates/base.html.twig — before the navbar #} +{% if is_granted('IS_IMPERSONATOR') %} + {{ component('Enabel:Ux:ImpersonateBanner', { + message: 'nav.impersonate.banner'|trans({'%name%': app.user.displayName}), + exitUrl: path('app_home', { _switch_user: '_exit' }), + exitLabel: 'nav.impersonate.exit'|trans, + }) }} +{% endif %} + + +``` + +The `{% if is_granted('IS_IMPERSONATOR') %}` gate keeps the banner — and the CSS layout offset — completely out of the page when nobody is impersonating. + +### Localized banner with French message + +```twig +{{ component('Enabel:Ux:ImpersonateBanner', { + message: 'Vous incarnez actuellement %name%'|trans({'%name%': app.user.displayName|e}), + exitUrl: path('app_home', { _switch_user: '_exit' }), + exitLabel: 'Quitter l\\'incarnation', +}) }} +``` + +Note the explicit `|e` filter on the substituted user name — the component renders `message` with `|raw` to allow the `` wrapper. + +### Custom icon + +```twig +{{ component('Enabel:Ux:ImpersonateBanner', { + message: 'Impersonating ' ~ app.user.displayName, + exitUrl: path('app_home', { _switch_user: '_exit' }), + icon: 'fa6-solid:mask', +}) }} +``` + +Any [Iconify](https://icones.js.org/) ID works (the same syntax as the other Enabel UX components: `Enabel:Ux:Callout`, `Enabel:Ux:Widget`, …). + +## CSS variable convention + +The `--app-navbar-offset` variable is set on `` via `body:has(.impersonate-banner)`. If your project layout reads that variable to set `padding-top` (e.g. `body { padding-top: var(--app-navbar-offset, 80px); }`), the banner-on-top-of-a-fixed-navbar case will lay out correctly out of the box. + +If you don't use that variable, the banner still renders fine — but you may want to either bump your fixed body padding by 28 px when the banner is present, or adopt the variable in your layout. + +## Adopting the component when you already have impersonate-banner CSS + +The component inlines its CSS through a ` +
+ + {{ ux_icon(icon, {class: 'me-1', height: '1rem'}) }} + {{ message|raw }} + + {{ exitLabel }} +
diff --git a/tests/Component/Navigation/ImpersonateBannerTest.php b/tests/Component/Navigation/ImpersonateBannerTest.php new file mode 100644 index 0000000..7fb3394 --- /dev/null +++ b/tests/Component/Navigation/ImpersonateBannerTest.php @@ -0,0 +1,128 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\Ux\Tests\Component\Navigation; + +use Enabel\Ux\Component\Navigation\ImpersonateBanner; +use PHPUnit\Framework\TestCase; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; +use Symfony\Component\OptionsResolver\Exception\MissingOptionsException; + +class ImpersonateBannerTest extends TestCase +{ + public function testComponentCanBeInstantiatedWithRequiredParameters(): void + { + $component = new ImpersonateBanner(); + $data = $component->preMount([ + 'message' => 'Impersonating Jane Doe', + 'exitUrl' => '?_switch_user=_exit', + ]); + + $component->message = $data['message']; + $component->exitUrl = $data['exitUrl']; + $component->exitLabel = $data['exitLabel']; + $component->icon = $data['icon']; + + $this->assertSame('Impersonating Jane Doe', $component->message); + $this->assertSame('?_switch_user=_exit', $component->exitUrl); + $this->assertSame('Exit impersonation', $component->exitLabel); + $this->assertSame('fa6-solid:user-secret', $component->icon); + } + + public function testComponentCanBeInstantiatedWithCustomParameters(): void + { + $component = new ImpersonateBanner(); + $data = $component->preMount([ + 'message' => 'Vous incarnez Jane Doe', + 'exitUrl' => '/?_switch_user=_exit', + 'exitLabel' => 'Quitter', + 'icon' => 'fa6-solid:mask', + ]); + + $this->assertSame('Vous incarnez Jane Doe', $data['message']); + $this->assertSame('/?_switch_user=_exit', $data['exitUrl']); + $this->assertSame('Quitter', $data['exitLabel']); + $this->assertSame('fa6-solid:mask', $data['icon']); + } + + public function testMissingMessageThrows(): void + { + $this->expectException(MissingOptionsException::class); + + $component = new ImpersonateBanner(); + $component->preMount(['exitUrl' => '?_switch_user=_exit']); + } + + public function testMissingExitUrlThrows(): void + { + $this->expectException(MissingOptionsException::class); + + $component = new ImpersonateBanner(); + $component->preMount(['message' => 'Impersonating']); + } + + public function testInvalidMessageTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new ImpersonateBanner(); + $component->preMount([ + 'message' => 123, + 'exitUrl' => '?_switch_user=_exit', + ]); + } + + public function testInvalidExitUrlTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new ImpersonateBanner(); + $component->preMount([ + 'message' => 'Impersonating', + 'exitUrl' => null, + ]); + } + + public function testInvalidExitLabelTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new ImpersonateBanner(); + $component->preMount([ + 'message' => 'Impersonating', + 'exitUrl' => '?_switch_user=_exit', + 'exitLabel' => false, + ]); + } + + public function testInvalidIconTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new ImpersonateBanner(); + $component->preMount([ + 'message' => 'Impersonating', + 'exitUrl' => '?_switch_user=_exit', + 'icon' => 42, + ]); + } + + public function testPreMountPreservesAdditionalData(): void + { + $component = new ImpersonateBanner(); + $data = $component->preMount([ + 'message' => 'Impersonating', + 'exitUrl' => '?_switch_user=_exit', + 'custom_attribute' => 'value', + ]); + + $this->assertArrayHasKey('custom_attribute', $data); + $this->assertSame('value', $data['custom_attribute']); + } +}