feat: add notification bell and dropdown to the app shell

This commit is contained in:
Bastian Wagner
2026-08-04 20:09:07 +02:00
parent 8ace676abf
commit b40af02e2f
4 changed files with 202 additions and 8 deletions

View File

@@ -12,6 +12,46 @@
} @else {
<span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span>
}
<span class="shell-header-spacer"></span>
<button
mat-icon-button
class="shell-notification-bell"
[matMenuTriggerFor]="notificationMenu"
(menuOpened)="onNotificationsMenuOpened()"
[matBadge]="unreadCount()"
[matBadgeHidden]="unreadCount() === 0"
matBadgeSize="small"
matBadgeColor="warn"
aria-label="Benachrichtigungen"
>
<mat-icon>notifications</mat-icon>
</button>
<mat-menu #notificationMenu="matMenu" class="shell-notification-menu">
<div class="shell-notification-menu__header">
<span>Benachrichtigungen</span>
<button mat-button (click)="onMarkAllRead()">Alle als gelesen markieren</button>
</div>
@if (notifications().length === 0) {
<div class="shell-notification-menu__empty">Keine Benachrichtigungen</div>
} @else {
@for (item of notifications(); track item.id) {
<button
mat-menu-item
class="shell-notification-menu__item"
[class.shell-notification-menu__item--unread]="!item.read"
(click)="onNotificationClick(item)"
>
<mat-icon>{{ notificationIcon(item) }}</mat-icon>
<span>{{ notificationLabel(item) }}</span>
</button>
}
@if (currentTeamId(); as teamId) {
<a mat-menu-item [routerLink]="['/team', teamId, 'notifications']">Alle anzeigen</a>
}
}
</mat-menu>
</mat-toolbar>
<main class="shell-content">

View File

@@ -53,3 +53,37 @@ main {
}
}
}
.shell-header-spacer {
flex: 1;
}
.shell-notification-bell {
color: var(--mat-sys-on-surface);
}
.shell-notification-menu {
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
gap: 0.5rem;
}
&__empty {
padding: 1rem;
color: var(--mat-sys-on-surface-variant);
font-size: 0.875rem;
}
&__item {
display: flex;
align-items: center;
gap: 0.5rem;
&--unread {
font-weight: 600;
}
}
}

View File

@@ -1,27 +1,55 @@
import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { ActivatedRoute, ParamMap, Router, convertToParamMap, provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Shell } from './shell';
import { environment } from '../../../../environments/environment';
import { AuthStore } from '../../auth/auth-store';
import { Player } from '../../../models/player.model';
import { NotificationsStore } from '../../notifications/notifications-store';
describe('Shell', () => {
let httpMock: HttpTestingController;
let authStore: AuthStore;
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let notificationsStore: {
unreadCount: ReturnType<typeof signal<number>>;
notifications: ReturnType<typeof signal<any[]>>;
startPolling: ReturnType<typeof vi.fn>;
loadRecent: ReturnType<typeof vi.fn>;
markRead: ReturnType<typeof vi.fn>;
markAllRead: ReturnType<typeof vi.fn>;
};
beforeEach(async () => {
localStorage.clear();
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
notificationsStore = {
unreadCount: signal(3),
notifications: signal([
{
id: 1,
event: 'player_creation',
actorUserId: 9,
payload: { playerId: 21, playerName: 'Ada Lovelace' },
read: false,
createdAt: '2026-08-04T10:00:00.000Z',
},
]),
startPolling: vi.fn(),
loadRecent: vi.fn(),
markRead: vi.fn(),
markAllRead: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [Shell],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{ provide: NotificationsStore, useValue: notificationsStore },
{
provide: ActivatedRoute,
useValue: { paramMap: routeParams.asObservable() },
@@ -153,4 +181,58 @@ describe('Shell', () => {
expect(httpMock.match((request) => request.url.includes('/teams/')).length).toBe(0);
});
it('starts polling notifications for the routed team id', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
expect(notificationsStore.startPolling).toHaveBeenCalledWith(5);
});
it('exposes the unread count from the notifications store', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
expect((fixture.componentInstance as any).unreadCount()).toBe(3);
});
it('loads recent notifications when the bell menu is opened', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
(fixture.componentInstance as any).onNotificationsMenuOpened();
expect(notificationsStore.loadRecent).toHaveBeenCalledWith(5);
});
it('marks a clicked notification as read and navigates to its target', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
const navigateSpy = vi.spyOn(TestBed.inject(Router), 'navigate');
const item = notificationsStore.notifications()[0];
(fixture.componentInstance as any).onNotificationClick(item);
expect(notificationsStore.markRead).toHaveBeenCalledWith(5, 1);
expect(navigateSpy).toHaveBeenCalledWith(['/team', 5, 'members', 21]);
});
it('marks all notifications as read', () => {
const fixture = TestBed.createComponent(Shell);
fixture.detectChanges();
httpMock.expectOne(`${environment.apiUrl}teams/5/overview`).flush({ id: 5, name: 'Team A', alias: 'a', balance: 0 });
httpMock.expectOne(`${environment.apiUrl}users/42/teams`).flush([]);
(fixture.componentInstance as any).onMarkAllRead();
expect(notificationsStore.markAllRead).toHaveBeenCalledWith(5);
});
});

View File

@@ -1,4 +1,4 @@
import { Component, computed, inject } from '@angular/core';
import { Component, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
ActivatedRoute,
@@ -7,6 +7,7 @@ import {
RouterLinkActive,
RouterOutlet,
} from '@angular/router';
import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
@@ -14,7 +15,14 @@ import { MatToolbarModule } from '@angular/material/toolbar';
import { AuthStore } from '../../auth/auth-store';
import { MyTeamsStore } from '../../team/my-teams-store';
import { TeamStore } from '../../team/team-store';
import { NotificationsStore } from '../../notifications/notifications-store';
import {
notificationIcon,
notificationLabel,
notificationTarget,
} from '../../notifications/notification-presentation';
import { UserTeamReference } from '../../../models/user-directory.model';
import { NotificationItem } from '../../../models/notification.model';
@Component({
selector: 'app-shell',
@@ -26,6 +34,7 @@ import { UserTeamReference } from '../../../models/user-directory.model';
MatIconModule,
MatMenuModule,
MatButtonModule,
MatBadgeModule,
],
templateUrl: './shell.html',
styleUrl: './shell.scss',
@@ -36,8 +45,12 @@ export class Shell {
private readonly authStore = inject(AuthStore);
private readonly myTeamsStore = inject(MyTeamsStore);
private readonly teamStore = inject(TeamStore);
private readonly notificationsStore = inject(NotificationsStore);
protected readonly currentTeam = this.teamStore.team;
protected readonly currentTeamId = signal<number | null>(null);
protected readonly unreadCount = this.notificationsStore.unreadCount;
protected readonly notifications = this.notificationsStore.notifications;
protected readonly myTeams = computed(() => {
const seen = new Set<number>();
@@ -57,17 +70,13 @@ export class Shell {
this.myTeamsStore.ensureLoaded(userId);
}
// A direct subscription (not `effect()` + `toSignal()`) so the initial
// team load happens synchronously during construction, exactly like
// `ensureLoaded` above — `ActivatedRoute.paramMap` always replays its
// current value synchronously to a new subscriber. This keeps the
// component's behavior deterministic and trivial to test: no signal
// effect scheduling to wait for.
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
const raw = params.get('id');
const id = raw === null ? Number.NaN : Number(raw);
if (Number.isInteger(id) && id > 0) {
this.teamStore.loadTeam(id);
this.currentTeamId.set(id);
this.notificationsStore.startPolling(id);
}
});
}
@@ -75,4 +84,33 @@ export class Shell {
protected switchTeam(teamId: number): void {
void this.router.navigate(['/team', teamId, 'overview']);
}
protected notificationLabel(item: NotificationItem): string {
return notificationLabel(item);
}
protected notificationIcon(item: NotificationItem): string {
return notificationIcon(item.event);
}
protected onNotificationsMenuOpened(): void {
const teamId = this.currentTeamId();
if (teamId !== null) {
this.notificationsStore.loadRecent(teamId);
}
}
protected onNotificationClick(item: NotificationItem): void {
const teamId = this.currentTeamId();
if (teamId === null) return;
this.notificationsStore.markRead(teamId, item.id);
void this.router.navigate(notificationTarget(item, teamId));
}
protected onMarkAllRead(): void {
const teamId = this.currentTeamId();
if (teamId !== null) {
this.notificationsStore.markAllRead(teamId);
}
}
}