feat: add full notifications history page and route
This commit is contained in:
@@ -123,6 +123,11 @@ export const routes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/team/more/guide/guide').then((m) => m.Guide),
|
import('./features/team/more/guide/guide').then((m) => m.Guide),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'notifications',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/team/notifications/notifications').then((m) => m.Notifications),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<div class="notifications-page">
|
||||||
|
<h1>Benachrichtigungen</h1>
|
||||||
|
|
||||||
|
@if (items().length === 0 && !loading()) {
|
||||||
|
<p class="notifications-page__empty">Keine Benachrichtigungen vorhanden.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<mat-nav-list>
|
||||||
|
@for (item of items(); track item.id) {
|
||||||
|
<a
|
||||||
|
mat-list-item
|
||||||
|
class="notifications-page__item"
|
||||||
|
[class.notifications-page__item--unread]="!item.read"
|
||||||
|
(click)="onItemClick(item)"
|
||||||
|
>
|
||||||
|
<mat-icon matListItemIcon>{{ notificationIcon(item) }}</mat-icon>
|
||||||
|
<span matListItemTitle>{{ notificationLabel(item) }}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</mat-nav-list>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<mat-spinner diameter="32" class="notifications-page__spinner" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (hasNextPage() && !loading()) {
|
||||||
|
<button mat-button (click)="loadMore()">Weitere laden</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.notifications-page {
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
color: var(--mat-sys-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item--unread {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__spinner {
|
||||||
|
margin: 1rem auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, ParamMap, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { BehaviorSubject, of } from 'rxjs';
|
||||||
|
import { Notifications } from './notifications';
|
||||||
|
import { NotificationsApi } from '../../../core/notifications/notifications-api';
|
||||||
|
import { NotificationsStore } from '../../../core/notifications/notifications-store';
|
||||||
|
import { NotificationItem } from '../../../models/notification.model';
|
||||||
|
|
||||||
|
describe('Notifications', () => {
|
||||||
|
let routeParams: BehaviorSubject<ParamMap>;
|
||||||
|
let fixture: ComponentFixture<Notifications>;
|
||||||
|
let api: { loadNotifications: ReturnType<typeof vi.fn> };
|
||||||
|
let store: { markRead: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
const item: NotificationItem = {
|
||||||
|
id: 1,
|
||||||
|
event: 'player_creation',
|
||||||
|
actorUserId: 9,
|
||||||
|
payload: { playerId: 21, playerName: 'Ada Lovelace' },
|
||||||
|
read: false,
|
||||||
|
createdAt: '2026-08-04T10:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
routeParams = new BehaviorSubject(convertToParamMap({ id: '5' }));
|
||||||
|
api = { loadNotifications: vi.fn() };
|
||||||
|
store = { markRead: vi.fn() };
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Notifications],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: NotificationsApi, useValue: api },
|
||||||
|
{ provide: NotificationsStore, useValue: store },
|
||||||
|
{ provide: ActivatedRoute, useValue: { parent: { paramMap: routeParams } } },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Notifications);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the first page for the routed team id', () => {
|
||||||
|
api.loadNotifications.mockReturnValue(of({ data: [item], page: 1, limit: 20, total: 1, hasNextPage: false }));
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(api.loadNotifications).toHaveBeenCalledWith(5, { page: 1, limit: 20 });
|
||||||
|
expect((fixture.componentInstance as any).items()).toEqual([item]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the next page and appends results', () => {
|
||||||
|
api.loadNotifications
|
||||||
|
.mockReturnValueOnce(of({ data: [item], page: 1, limit: 20, total: 21, hasNextPage: true }))
|
||||||
|
.mockReturnValueOnce(of({ data: [{ ...item, id: 2 }], page: 2, limit: 20, total: 21, hasNextPage: false }));
|
||||||
|
|
||||||
|
fixture.detectChanges();
|
||||||
|
(fixture.componentInstance as any).loadMore();
|
||||||
|
|
||||||
|
expect(api.loadNotifications).toHaveBeenLastCalledWith(5, { page: 2, limit: 20 });
|
||||||
|
expect((fixture.componentInstance as any).items().length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a clicked item as read and navigates to its target', () => {
|
||||||
|
api.loadNotifications.mockReturnValue(of({ data: [item], page: 1, limit: 20, total: 1, hasNextPage: false }));
|
||||||
|
fixture.detectChanges();
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const navigateSpy = vi.spyOn(router, 'navigate');
|
||||||
|
|
||||||
|
(fixture.componentInstance as any).onItemClick(item);
|
||||||
|
|
||||||
|
expect(store.markRead).toHaveBeenCalledWith(5, 1);
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith(['/team', 5, 'members', 21]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Component, DestroyRef, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatListModule } from '@angular/material/list';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { NotificationItem } from '../../../models/notification.model';
|
||||||
|
import { NotificationsApi } from '../../../core/notifications/notifications-api';
|
||||||
|
import { NotificationsStore } from '../../../core/notifications/notifications-store';
|
||||||
|
import {
|
||||||
|
notificationIcon,
|
||||||
|
notificationLabel,
|
||||||
|
notificationTarget,
|
||||||
|
} from '../../../core/notifications/notification-presentation';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-notifications',
|
||||||
|
imports: [MatButtonModule, MatIconModule, MatListModule, MatProgressSpinnerModule],
|
||||||
|
templateUrl: './notifications.html',
|
||||||
|
styleUrl: './notifications.scss',
|
||||||
|
})
|
||||||
|
export class Notifications implements OnInit {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly api = inject(NotificationsApi);
|
||||||
|
private readonly notificationsStore = inject(NotificationsStore);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
protected readonly items = signal<NotificationItem[]>([]);
|
||||||
|
protected readonly loading = signal(false);
|
||||||
|
protected readonly hasNextPage = signal(false);
|
||||||
|
|
||||||
|
private teamId: number | null = null;
|
||||||
|
private page = 1;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const parentRoute = this.route.parent;
|
||||||
|
if (!parentRoute) return;
|
||||||
|
|
||||||
|
parentRoute.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
|
||||||
|
const raw = params.get('id');
|
||||||
|
const id = raw === null ? Number.NaN : Number(raw);
|
||||||
|
if (Number.isInteger(id) && id > 0 && id !== this.teamId) {
|
||||||
|
this.teamId = id;
|
||||||
|
this.page = 1;
|
||||||
|
this.items.set([]);
|
||||||
|
this.hasNextPage.set(false);
|
||||||
|
this.loadPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected notificationLabel(item: NotificationItem): string {
|
||||||
|
return notificationLabel(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected notificationIcon(item: NotificationItem): string {
|
||||||
|
return notificationIcon(item.event);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected loadMore(): void {
|
||||||
|
this.page += 1;
|
||||||
|
this.loadPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected onItemClick(item: NotificationItem): void {
|
||||||
|
if (this.teamId === null) return;
|
||||||
|
const teamId = this.teamId;
|
||||||
|
this.notificationsStore.markRead(teamId, item.id);
|
||||||
|
this.items.update((current) =>
|
||||||
|
current.map((entry) => (entry.id === item.id ? { ...entry, read: true } : entry)),
|
||||||
|
);
|
||||||
|
void this.router.navigate(notificationTarget(item, teamId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadPage(): void {
|
||||||
|
if (this.teamId === null) return;
|
||||||
|
const teamId = this.teamId;
|
||||||
|
this.loading.set(true);
|
||||||
|
this.api.loadNotifications(teamId, { page: this.page, limit: PAGE_SIZE }).subscribe({
|
||||||
|
next: (result) => {
|
||||||
|
this.items.update((current) => [...current, ...result.data]);
|
||||||
|
this.hasNextPage.set(result.hasNextPage);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => this.loading.set(false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user