feat: add notification bell and dropdown to the app shell
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user