feat: mount EnvBanner app-wide and expose --env-banner-height

Renders the banner once at the app root so every route picks it up,
and exposes its height as a CSS custom property so fixed-viewport
layouts (Shell, public pages) can compensate for it.
This commit is contained in:
Bastian Wagner
2026-08-05 12:30:50 +02:00
parent 95a667cb98
commit fc0c1ed522
3 changed files with 37 additions and 62 deletions

View File

@@ -1 +1,2 @@
<app-env-banner />
<router-outlet /> <router-outlet />

View File

@@ -1,76 +1,44 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { App } from './app'; import { App } from './app';
import { AuthApi } from './core/auth/auth-api'; import { environment } from '../environments/environment';
import { AuthStore } from './core/auth/auth-store';
describe('App', () => { describe('App', () => {
const token = signal<string | null>(null); const originalProduction = environment.production;
const updateUser = vi.fn();
const meResponse = signal<Record<string, unknown>>({
id: 1,
email: 'a@b.de',
firstName: 'Alex',
lastName: 'Muster',
});
const me = vi.fn(() => of(meResponse()));
const setSession = vi.fn();
beforeEach(async () => { beforeEach(() => {
token.set(null); localStorage.clear();
meResponse.set({ id: 1, email: 'a@b.de', firstName: 'Alex', lastName: 'Muster' }); });
updateUser.mockClear();
setSession.mockClear(); afterEach(() => {
me.mockClear(); environment.production = originalProduction;
});
it('sets --env-banner-height to 0px and renders no banner in production', async () => {
environment.production = true;
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: [ providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
provideRouter([]),
{ provide: AuthStore, useValue: { token, updateUser, setSession } },
{ provide: AuthApi, useValue: { me } },
],
}).compileComponents(); }).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
expect(fixture.componentInstance).toBeTruthy(); fixture.detectChanges();
expect(fixture.nativeElement.style.getPropertyValue('--env-banner-height')).toBe('0px');
expect(fixture.nativeElement.querySelector('.env-banner')).toBeNull();
}); });
it('validates and refreshes a restored session on startup', () => { it('sets --env-banner-height to 28px and renders the banner outside production', async () => {
token.set('stored-token'); environment.production = false;
await TestBed.configureTestingModule({
imports: [App],
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])],
}).compileComponents();
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
TestBed.createComponent(App); expect(fixture.nativeElement.style.getPropertyValue('--env-banner-height')).toBe('28px');
expect(fixture.nativeElement.querySelector('.env-banner')).not.toBeNull();
expect(me).toHaveBeenCalled();
expect(updateUser).toHaveBeenCalledWith({
id: 1,
email: 'a@b.de',
firstName: 'Alex',
lastName: 'Muster',
});
});
it('replaces an expired stored token when me returns a refreshed token', () => {
token.set('expired-token');
meResponse.set({
id: 1,
email: 'a@b.de',
firstName: 'Alex',
lastName: 'Muster',
token: 'refreshed-token',
});
TestBed.createComponent(App);
expect(setSession).toHaveBeenCalledWith('refreshed-token', {
id: 1,
email: 'a@b.de',
firstName: 'Alex',
lastName: 'Muster',
});
expect(updateUser).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -2,16 +2,22 @@ import { Component, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router'; import { RouterOutlet } from '@angular/router';
import { AuthApi } from './core/auth/auth-api'; import { AuthApi } from './core/auth/auth-api';
import { AuthStore } from './core/auth/auth-store'; import { AuthStore } from './core/auth/auth-store';
import { environment } from '../environments/environment';
import { ENV_BANNER_HEIGHT_PX, EnvBanner } from './shared/env-banner/env-banner';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet], imports: [RouterOutlet, EnvBanner],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss', styleUrl: './app.scss',
host: {
'[style.--env-banner-height]': 'bannerHeight',
},
}) })
export class App { export class App {
private readonly authApi = inject(AuthApi); private readonly authApi = inject(AuthApi);
private readonly authStore = inject(AuthStore); private readonly authStore = inject(AuthStore);
protected readonly bannerHeight = `${environment.production ? 0 : ENV_BANNER_HEIGHT_PX}px`;
constructor() { constructor() {
if (this.authStore.token()) { if (this.authStore.token()) {