feat: add notification bell and dropdown to the app shell
This commit is contained in:
@@ -12,6 +12,46 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<span>{{ currentTeam()?.name ?? 'TeamWallet' }}</span>
|
<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>
|
</mat-toolbar>
|
||||||
|
|
||||||
<main class="shell-content">
|
<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 { TestBed } from '@angular/core/testing';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
import { BehaviorSubject } from 'rxjs';
|
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 { provideHttpClient } from '@angular/common/http';
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { Shell } from './shell';
|
import { Shell } from './shell';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import { AuthStore } from '../../auth/auth-store';
|
import { AuthStore } from '../../auth/auth-store';
|
||||||
import { Player } from '../../../models/player.model';
|
import { Player } from '../../../models/player.model';
|
||||||
|
import { NotificationsStore } from '../../notifications/notifications-store';
|
||||||
|
|
||||||
describe('Shell', () => {
|
describe('Shell', () => {
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
let authStore: AuthStore;
|
let authStore: AuthStore;
|
||||||
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
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 () => {
|
beforeEach(async () => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
|
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({
|
await TestBed.configureTestingModule({
|
||||||
imports: [Shell],
|
imports: [Shell],
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(),
|
provideHttpClient(),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideRouter([]),
|
provideRouter([]),
|
||||||
|
{ provide: NotificationsStore, useValue: notificationsStore },
|
||||||
{
|
{
|
||||||
provide: ActivatedRoute,
|
provide: ActivatedRoute,
|
||||||
useValue: { paramMap: routeParams.asObservable() },
|
useValue: { paramMap: routeParams.asObservable() },
|
||||||
@@ -153,4 +181,58 @@ describe('Shell', () => {
|
|||||||
|
|
||||||
expect(httpMock.match((request) => request.url.includes('/teams/')).length).toBe(0);
|
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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import {
|
import {
|
||||||
ActivatedRoute,
|
ActivatedRoute,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
RouterLinkActive,
|
RouterLinkActive,
|
||||||
RouterOutlet,
|
RouterOutlet,
|
||||||
} from '@angular/router';
|
} from '@angular/router';
|
||||||
|
import { MatBadgeModule } from '@angular/material/badge';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
@@ -14,7 +15,14 @@ import { MatToolbarModule } from '@angular/material/toolbar';
|
|||||||
import { AuthStore } from '../../auth/auth-store';
|
import { AuthStore } from '../../auth/auth-store';
|
||||||
import { MyTeamsStore } from '../../team/my-teams-store';
|
import { MyTeamsStore } from '../../team/my-teams-store';
|
||||||
import { TeamStore } from '../../team/team-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 { UserTeamReference } from '../../../models/user-directory.model';
|
||||||
|
import { NotificationItem } from '../../../models/notification.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-shell',
|
selector: 'app-shell',
|
||||||
@@ -26,6 +34,7 @@ import { UserTeamReference } from '../../../models/user-directory.model';
|
|||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
MatBadgeModule,
|
||||||
],
|
],
|
||||||
templateUrl: './shell.html',
|
templateUrl: './shell.html',
|
||||||
styleUrl: './shell.scss',
|
styleUrl: './shell.scss',
|
||||||
@@ -36,8 +45,12 @@ export class Shell {
|
|||||||
private readonly authStore = inject(AuthStore);
|
private readonly authStore = inject(AuthStore);
|
||||||
private readonly myTeamsStore = inject(MyTeamsStore);
|
private readonly myTeamsStore = inject(MyTeamsStore);
|
||||||
private readonly teamStore = inject(TeamStore);
|
private readonly teamStore = inject(TeamStore);
|
||||||
|
private readonly notificationsStore = inject(NotificationsStore);
|
||||||
|
|
||||||
protected readonly currentTeam = this.teamStore.team;
|
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(() => {
|
protected readonly myTeams = computed(() => {
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
@@ -57,17 +70,13 @@ export class Shell {
|
|||||||
this.myTeamsStore.ensureLoaded(userId);
|
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) => {
|
this.route.paramMap.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||||
const raw = params.get('id');
|
const raw = params.get('id');
|
||||||
const id = raw === null ? Number.NaN : Number(raw);
|
const id = raw === null ? Number.NaN : Number(raw);
|
||||||
if (Number.isInteger(id) && id > 0) {
|
if (Number.isInteger(id) && id > 0) {
|
||||||
this.teamStore.loadTeam(id);
|
this.teamStore.loadTeam(id);
|
||||||
|
this.currentTeamId.set(id);
|
||||||
|
this.notificationsStore.startPolling(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -75,4 +84,33 @@ export class Shell {
|
|||||||
protected switchTeam(teamId: number): void {
|
protected switchTeam(teamId: number): void {
|
||||||
void this.router.navigate(['/team', teamId, 'overview']);
|
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