fix: stabilize admin user UI state

This commit is contained in:
Bastian Wagner
2026-08-01 10:32:51 +02:00
parent 3ae0fd2000
commit 9dc9dc3dcf
6 changed files with 358 additions and 84 deletions

View File

@@ -1,12 +1,13 @@
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { Component, EventEmitter, Input, OnChanges, Output, inject, signal } from '@angular/core'; import { Component, DestroyRef, EventEmitter, Input, OnChanges, Output, SimpleChanges, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { filter, finalize, take } from 'rxjs'; import { EMPTY, Subject, catchError, filter, finalize, switchMap, take, tap } from 'rxjs';
import { AdminUsersApi } from '../../core/users/admin-users-api'; import { AdminUsersApi } from '../../core/users/admin-users-api';
import { import {
AdminPlayerPage, AdminPlayerPage,
@@ -25,34 +26,51 @@ import { ConfirmDialog } from '../../shared/confirm-dialog/confirm-dialog';
MatProgressSpinnerModule, MatProgressSpinnerModule,
], ],
template: ` template: `
<section class="assignments" aria-label="Spielerzuordnungen verwalten"> <section
class="assignments"
aria-label="Spielerzuordnungen verwalten"
[attr.aria-busy]="pendingPlayerId() !== null ? 'true' : null"
>
<div class="assignments__heading"> <div class="assignments__heading">
<div> <div>
<h3>Spielerzuordnungen</h3> <h3>Spielerzuordnungen</h3>
<p>Spieler teamübergreifend suchen und sicher verknüpfen.</p> <p>Spieler teamübergreifend suchen und sicher verknüpfen.</p>
</div> </div>
<button mat-button type="button" (click)="closed.emit()">Schließen</button> <button mat-button type="button" [disabled]="pendingPlayerId() !== null" (click)="close()">Schließen</button>
</div> </div>
<form class="assignments__search" (submit)="submitSearch(); $event.preventDefault()" role="search"> <form class="assignments__search" (submit)="submitSearch(); $event.preventDefault()" role="search">
<mat-form-field appearance="outline" subscriptSizing="dynamic"> <mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Spieler suchen</mat-label> <mat-label>Spieler suchen</mat-label>
<mat-icon matPrefix>search</mat-icon> <mat-icon matPrefix>search</mat-icon>
<input matInput type="search" name="playerSearch" [value]="searchDraft" (input)="searchDraft = $any($event.target).value" /> <input
matInput
type="search"
name="playerSearch"
[value]="searchDraft"
[disabled]="pendingPlayerId() !== null"
(input)="searchDraft = $any($event.target).value"
/>
</mat-form-field> </mat-form-field>
<button mat-stroked-button type="submit">Suchen</button> <button mat-stroked-button type="submit" [disabled]="pendingPlayerId() !== null">Suchen</button>
</form> </form>
@if (error()) { @if (loadError()) {
<div class="assignments__error" role="alert"> <div class="assignments__error" role="alert">
<span>{{ error() }}</span> <span>{{ loadError() }}</span>
<button mat-button type="button" (click)="loadPlayers()">Erneut versuchen</button> <button mat-button type="button" (click)="loadPlayers()">Erneut versuchen</button>
</div> </div>
} }
@if (mutationError()) {
<div class="assignments__error" role="alert">{{ mutationError() }}</div>
}
@if (loading()) { @if (loading()) {
<div class="assignments__state"><mat-spinner diameter="30" /></div> <div class="assignments__state" aria-live="polite">
} @else if (!error() && players()?.data?.length === 0) { <mat-spinner diameter="30" />
<span>Spieler werden geladen …</span>
</div>
} @else if (!loadError() && players()?.data?.length === 0) {
<div class="assignments__state"><span>Keine Spieler gefunden.</span></div> <div class="assignments__state"><span>Keine Spieler gefunden.</span></div>
} @else if (players()) { } @else if (players()) {
<div class="player-list"> <div class="player-list">
@@ -98,15 +116,15 @@ import { ConfirmDialog } from '../../shared/confirm-dialog/confirm-dialog';
</div> </div>
} }
</div> </div>
<div class="assignments__paging" aria-label="Spielerseiten"> <nav class="assignments__paging" aria-label="Spielerseiten">
<button mat-button type="button" [disabled]="players()!.page <= 1 || loading()" (click)="previousPage()"> <button mat-button type="button" [disabled]="players()!.page <= 1 || loading() || pendingPlayerId() !== null" (click)="previousPage()">
<mat-icon>chevron_left</mat-icon>Zurück <mat-icon>chevron_left</mat-icon>Zurück
</button> </button>
<span>Seite {{ players()!.page }}</span> <span>Seite {{ players()!.page }}</span>
<button mat-button type="button" [disabled]="!players()!.hasNextPage || loading()" (click)="nextPage()"> <button mat-button type="button" [disabled]="!players()!.hasNextPage || loading() || pendingPlayerId() !== null" (click)="nextPage()">
Weiter<mat-icon>chevron_right</mat-icon> Weiter<mat-icon>chevron_right</mat-icon>
</button> </button>
</div> </nav>
} }
</section> </section>
`, `,
@@ -135,21 +153,58 @@ import { ConfirmDialog } from '../../shared/confirm-dialog/confirm-dialog';
export class PlayerAssignments implements OnChanges { export class PlayerAssignments implements OnChanges {
private readonly adminUsersApi = inject(AdminUsersApi); private readonly adminUsersApi = inject(AdminUsersApi);
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
private readonly destroyRef = inject(DestroyRef);
private readonly playerRequests = new Subject<void>();
@Input({ required: true }) user!: AdminUserDirectorySummary; @Input({ required: true }) user!: AdminUserDirectorySummary;
@Output() readonly directoryChanged = new EventEmitter<void>(); @Output() readonly directoryChanged = new EventEmitter<void>();
@Output() readonly closed = new EventEmitter<void>(); @Output() readonly closed = new EventEmitter<void>();
@Output() readonly busyChange = new EventEmitter<boolean>();
protected readonly players = signal<AdminPlayerPage | null>(null); protected readonly players = signal<AdminPlayerPage | null>(null);
protected readonly loading = signal(false); protected readonly loading = signal(false);
protected readonly error = signal<string | null>(null); protected readonly loadError = signal<string | null>(null);
protected readonly mutationError = signal<string | null>(null);
protected readonly pendingPlayerId = signal<number | null>(null); protected readonly pendingPlayerId = signal<number | null>(null);
protected searchDraft = ''; protected searchDraft = '';
private search = ''; private search = '';
private page = 1; private page = 1;
private readonly limit = 20; private readonly limit = 20;
ngOnChanges(): void { constructor() {
this.playerRequests
.pipe(
switchMap(() => {
this.loading.set(true);
this.loadError.set(null);
this.players.set(null);
return this.adminUsersApi
.loadPlayers({
assignment: 'all',
page: this.page,
limit: this.limit,
...(this.search ? { search: this.search } : {}),
})
.pipe(
tap((players) => this.players.set(players)),
catchError((error: HttpErrorResponse) => {
this.loadError.set(this.loadErrorMessage(error));
return EMPTY;
}),
finalize(() => this.loading.set(false)),
);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
ngOnChanges(changes: SimpleChanges): void {
const userChange = changes['user'];
if (!userChange) return;
const previous = userChange.previousValue as AdminUserDirectorySummary | undefined;
const current = userChange.currentValue as AdminUserDirectorySummary;
if (!userChange.firstChange && previous?.id === current.id) return;
this.searchDraft = ''; this.searchDraft = '';
this.search = ''; this.search = '';
this.page = 1; this.page = 1;
@@ -157,36 +212,26 @@ export class PlayerAssignments implements OnChanges {
} }
protected loadPlayers(): void { protected loadPlayers(): void {
this.loading.set(true); this.playerRequests.next();
this.error.set(null);
this.adminUsersApi
.loadPlayers({
assignment: 'all',
page: this.page,
limit: this.limit,
...(this.search ? { search: this.search } : {}),
})
.pipe(finalize(() => this.loading.set(false)))
.subscribe({
next: (players) => this.players.set(players),
error: (error: HttpErrorResponse) => this.error.set(this.errorMessage(error)),
});
} }
protected submitSearch(): void { protected submitSearch(): void {
this.search = this.searchDraft.trim(); if (this.pendingPlayerId() !== null) return;
const search = this.searchDraft.trim();
if (this.loading() && search === this.search && this.page === 1) return;
this.search = search;
this.page = 1; this.page = 1;
this.loadPlayers(); this.loadPlayers();
} }
protected previousPage(): void { protected previousPage(): void {
if (this.page <= 1 || this.loading()) return; if (this.page <= 1 || this.loading() || this.pendingPlayerId() !== null) return;
this.page -= 1; this.page -= 1;
this.loadPlayers(); this.loadPlayers();
} }
protected nextPage(): void { protected nextPage(): void {
if (!this.players()?.hasNextPage || this.loading()) return; if (!this.players()?.hasNextPage || this.loading() || this.pendingPlayerId() !== null) return;
this.page += 1; this.page += 1;
this.loadPlayers(); this.loadPlayers();
} }
@@ -195,6 +240,10 @@ export class PlayerAssignments implements OnChanges {
this.runAssignment(player); this.runAssignment(player);
} }
protected close(): void {
if (this.pendingPlayerId() === null) this.closed.emit();
}
protected confirmUnlink(player: AdminPlayerSummary): void { protected confirmUnlink(player: AdminPlayerSummary): void {
this.confirm({ this.confirm({
title: 'Verknüpfung lösen?', title: 'Verknüpfung lösen?',
@@ -223,32 +272,32 @@ export class PlayerAssignments implements OnChanges {
return this.dialog return this.dialog
.open(ConfirmDialog, { data, restoreFocus: true }) .open(ConfirmDialog, { data, restoreFocus: true })
.afterClosed() .afterClosed()
.pipe(filter(Boolean), take(1)); .pipe(filter(Boolean), take(1), takeUntilDestroyed(this.destroyRef));
} }
private runAssignment(player: AdminPlayerSummary): void { private runAssignment(player: AdminPlayerSummary): void {
if (this.pendingPlayerId() !== null) return; if (this.pendingPlayerId() !== null) return;
this.pendingPlayerId.set(player.id); this.setPending(player.id);
this.error.set(null); this.mutationError.set(null);
this.adminUsersApi this.adminUsersApi
.assignPlayer(this.user.id, player.id) .assignPlayer(this.user.id, player.id)
.pipe(finalize(() => this.pendingPlayerId.set(null))) .pipe(finalize(() => this.setPending(null)), takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => this.refreshAfterMutation(), next: () => this.refreshAfterMutation(),
error: (error: HttpErrorResponse) => this.error.set(this.errorMessage(error)), error: (error: HttpErrorResponse) => this.mutationError.set(this.mutationErrorMessage(error)),
}); });
} }
private runUnlink(player: AdminPlayerSummary): void { private runUnlink(player: AdminPlayerSummary): void {
if (this.pendingPlayerId() !== null) return; if (this.pendingPlayerId() !== null) return;
this.pendingPlayerId.set(player.id); this.setPending(player.id);
this.error.set(null); this.mutationError.set(null);
this.adminUsersApi this.adminUsersApi
.unlinkPlayer(this.user.id, player.id) .unlinkPlayer(this.user.id, player.id)
.pipe(finalize(() => this.pendingPlayerId.set(null))) .pipe(finalize(() => this.setPending(null)), takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => this.refreshAfterMutation(), next: () => this.refreshAfterMutation(),
error: (error: HttpErrorResponse) => this.error.set(this.errorMessage(error)), error: (error: HttpErrorResponse) => this.mutationError.set(this.mutationErrorMessage(error)),
}); });
} }
@@ -257,7 +306,18 @@ export class PlayerAssignments implements OnChanges {
this.loadPlayers(); this.loadPlayers();
} }
private errorMessage(error: HttpErrorResponse): string { private setPending(playerId: number | null): void {
this.pendingPlayerId.set(playerId);
this.busyChange.emit(playerId !== null);
}
private loadErrorMessage(error: HttpErrorResponse): string {
const detail = typeof error.error?.message === 'string' ? error.error.message : '';
const fallback = error.status === 403 ? 'Keine Berechtigung.' : 'Spieler konnten nicht geladen werden.';
return detail ? `${fallback} ${detail}` : fallback;
}
private mutationErrorMessage(error: HttpErrorResponse): string {
const detail = typeof error.error?.message === 'string' ? error.error.message : ''; const detail = typeof error.error?.message === 'string' ? error.error.message : '';
const fallback = error.status === 403 ? 'Keine Berechtigung.' : 'Zuordnung konnte nicht geändert werden.'; const fallback = error.status === 403 ? 'Keine Berechtigung.' : 'Zuordnung konnte nicht geändert werden.';
return detail ? `${fallback} ${detail}` : fallback; return detail ? `${fallback} ${detail}` : fallback;

View File

@@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
@@ -66,10 +66,23 @@ export class UserEdit implements OnChanges {
protected lastName = ''; protected lastName = '';
protected role: AdminUserRoleId = 2; protected role: AdminUserRoleId = 2;
ngOnChanges(): void { ngOnChanges(changes: SimpleChanges): void {
this.firstName = this.user.firstName ?? ''; const userChange = changes['user'];
this.lastName = this.user.lastName ?? ''; if (!userChange) return;
this.role = this.user.role?.id === 1 ? 1 : 2; const previous = userChange.previousValue as AdminUserDirectorySummary | undefined;
const current = userChange.currentValue as AdminUserDirectorySummary;
if (
!userChange.firstChange &&
previous?.id === current.id &&
previous.firstName === current.firstName &&
previous.lastName === current.lastName &&
previous.role?.id === current.role?.id
) {
return;
}
this.firstName = current.firstName ?? '';
this.lastName = current.lastName ?? '';
this.role = current.role?.id === 1 ? 1 : 2;
} }
protected submit(): void { protected submit(): void {

View File

@@ -45,7 +45,12 @@
} @else if (directory()) { } @else if (directory()) {
<div class="directory" aria-live="polite"> <div class="directory" aria-live="polite">
@for (user of directory()!.data; track user.id) { @for (user of directory()!.data; track user.id) {
<article class="user-row" [attr.data-user-id]="user.id" [class.user-row--inactive]="user.status?.id === 2"> <article
class="user-row"
[attr.data-user-id]="user.id"
[attr.aria-busy]="pendingUserId() === user.id ? 'true' : null"
[class.user-row--inactive]="user.status?.id === 2"
>
<div class="user-row__summary"> <div class="user-row__summary">
<div class="avatar" aria-hidden="true">{{ initials(user) }}</div> <div class="avatar" aria-hidden="true">{{ initials(user) }}</div>
<div class="user-row__identity"> <div class="user-row__identity">
@@ -76,18 +81,39 @@
@if (adminDetails(user); as details) { @if (adminDetails(user); as details) {
<div class="user-row__actions"> <div class="user-row__actions">
<button mat-button type="button" (click)="toggleEdit(user.id)">Bearbeiten</button> <button mat-button type="button" [disabled]="assignmentBusyUserId() !== null" (click)="toggleEdit(user.id)">
<button mat-button type="button" (click)="toggleAssignments(user.id)">Zuordnungen verwalten</button> Bearbeiten
</button>
<button
mat-button
type="button"
[disabled]="assignmentBusyUserId() !== null"
(click)="toggleAssignments(user.id)"
>
Zuordnungen verwalten
</button>
<button <button
mat-stroked-button mat-stroked-button
type="button" type="button"
[disabled]="isSelf(user) || pendingUserId() !== null" [disabled]="isSelf(user) || pendingUserId() !== null"
[attr.title]="isSelf(user) ? 'Das eigene Konto kann nicht deaktiviert werden.' : null" [attr.title]="isSelf(user) ? 'Das eigene Konto kann nicht deaktiviert werden.' : null"
[attr.aria-describedby]="isSelf(user) ? 'self-status-note-' + user.id : null"
(click)="changeStatus(details)" (click)="changeStatus(details)"
> >
{{ user.status?.id === 2 ? 'Aktivieren' : 'Deaktivieren' }} {{
pendingUserId() === user.id
? 'Status wird geändert …'
: user.status?.id === 2
? 'Aktivieren'
: 'Deaktivieren'
}}
</button> </button>
</div> </div>
@if (isSelf(user)) {
<p class="self-status-note" [id]="'self-status-note-' + user.id">
Das eigene Konto kann nicht deaktiviert werden.
</p>
}
@if (editingUserId() === user.id) { @if (editingUserId() === user.id) {
<app-user-edit <app-user-edit
@@ -103,6 +129,7 @@
[user]="details" [user]="details"
(directoryChanged)="assignmentsChanged()" (directoryChanged)="assignmentsChanged()"
(closed)="assignmentUserId.set(null)" (closed)="assignmentUserId.set(null)"
(busyChange)="assignmentBusyChanged(user.id, $event)"
/> />
} }
} }

View File

@@ -133,6 +133,13 @@ h1 {
gap: 6px; gap: 6px;
} }
.self-status-note {
margin: -8px 0 0;
color: var(--mat-sys-on-surface-variant);
font-size: 0.8rem;
text-align: right;
}
.page-state { .page-state {
min-height: 240px; min-height: 240px;
display: grid; display: grid;

View File

@@ -94,7 +94,7 @@ describe('Users directory', () => {
http = TestBed.inject(HttpTestingController); http = TestBed.inject(HttpTestingController);
}); });
afterEach(() => http.verify()); afterEach(() => http.verify({ ignoreCancelled: true }));
function create(): void { function create(): void {
fixture = TestBed.createComponent(Users); fixture = TestBed.createComponent(Users);
@@ -113,6 +113,13 @@ describe('Users directory', () => {
return match as HTMLButtonElement; return match as HTMLButtonElement;
} }
function childButton(host: HTMLElement, label: string): HTMLButtonElement {
const match = [...host.querySelectorAll('button')].find((element) => element.textContent?.includes(label));
if (!match) throw new Error(`Missing child button: ${label}`);
return match as HTMLButtonElement;
}
function flushDirectory(page = directoryPage()): void { function flushDirectory(page = directoryPage()): void {
http.expectOne(`${api}?page=1&limit=20`).flush(page); http.expectOne(`${api}?page=1&limit=20`).flush(page);
fixture.detectChanges(); fixture.detectChanges();
@@ -147,6 +154,25 @@ describe('Users directory', () => {
http.expectOne(`${api}?page=2&limit=20`).flush(directoryPage([admin], 2, 12, false)); http.expectOne(`${api}?page=2&limit=20`).flush(directoryPage([admin], 2, 12, false));
}); });
it('cancels an older directory load when a newer search starts', () => {
create();
const older = http.expectOne(`${api}?page=1&limit=20`);
const search = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[type="search"]')!;
search.value = 'Grace';
search.dispatchEvent(new Event('input'));
search.closest('form')!.dispatchEvent(new Event('submit'));
const newer = http.expectOne(`${api}?page=1&limit=20&search=Grace`);
const olderWasCancelled = older.cancelled;
newer.flush(directoryPage([admin]));
if (!olderWasCancelled) older.flush(directoryPage([ada]));
fixture.detectChanges();
expect(olderWasCancelled).toBe(true);
expect(text()).toContain('Grace Admin');
expect(text()).not.toContain('Ada Lovelace');
});
it('redacts admin-only data and controls for a non-admin even if extra fields arrive', () => { it('redacts admin-only data and controls for a non-admin even if extra fields arrive', () => {
create(); create();
flushDirectory(directoryPage([ada])); flushDirectory(directoryPage([ada]));
@@ -208,6 +234,31 @@ describe('Users directory', () => {
expect(text()).toContain('Augusta King'); expect(text()).toContain('Augusta King');
}); });
it('keeps the editor draft while saving input changes and on an initial profile failure', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Bearbeiten').click();
fixture.detectChanges();
const firstName = (fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[name="firstName"]')!;
firstName.value = 'Operator draft';
firstName.dispatchEvent(new Event('input'));
firstName.closest('form')!.dispatchEvent(new Event('submit'));
fixture.detectChanges();
expect(firstName.value).toBe('Operator draft');
http.expectOne(`${adminApi}/7/profile`).flush(
{ message: 'Profile rejected' },
{ status: 400, statusText: 'Bad Request' },
);
http.expectNone(`${api}?page=1&limit=20`);
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector<HTMLInputElement>('input[name="firstName"]')!.value).toBe(
'Operator draft',
);
expect(text()).toContain('Profile rejected');
});
it('reloads authoritative directory state when a role change fails after the profile was saved', () => { it('reloads authoritative directory state when a role change fails after the profile was saved', () => {
isAdmin.set(true); isAdmin.set(true);
create(); create();
@@ -241,11 +292,69 @@ describe('Users directory', () => {
closeDialog.next(true); closeDialog.next(true);
const request = http.expectOne(`${adminApi}/7/status`); const request = http.expectOne(`${adminApi}/7/status`);
expect(request.request.body).toEqual({ status: 2 }); expect(request.request.body).toEqual({ status: 2 });
fixture.detectChanges();
expect(text()).toContain('Status wird geändert');
expect((fixture.nativeElement as HTMLElement).querySelector('[data-user-id="7"]')?.getAttribute('aria-busy')).toBe('true');
request.flush(ada); request.flush(ada);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([{ ...ada, status: { id: 2, name: 'Inactive' } }])); http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([{ ...ada, status: { id: 2, name: 'Inactive' } }]));
}); });
it('loads searchable player results and assigns an unlinked player before refreshing both lists', () => { it('searches players from page one after paging and assigns before refreshing both lists', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Zuordnungen verwalten').click();
fixture.detectChanges();
const panel = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!;
expect(panel.textContent).toContain('Spieler werden geladen');
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage({ hasNextPage: true, total: 21 }));
fixture.detectChanges();
expect(panel.querySelector('nav[aria-label="Spielerseiten"]')).not.toBeNull();
childButton(panel, 'Weiter').click();
http.expectOne(`${adminApi}/players?assignment=all&page=2&limit=20`).flush(playersPage({ page: 2 }));
fixture.detectChanges();
const playerSearch = panel.querySelector<HTMLInputElement>('input[type="search"]')!;
playerSearch.value = 'Linus';
playerSearch.dispatchEvent(new Event('input'));
playerSearch.closest('form')!.dispatchEvent(new Event('submit'));
http.expectOne(`${adminApi}/players?search=Linus&assignment=all&page=1&limit=20`).flush(playersPage());
fixture.detectChanges();
expect(text()).toContain('Linus Player');
expect(text()).toContain('Second Team');
childButton(panel, 'Zuordnen').click();
const assign = http.expectOne(`${adminApi}/7/players/202`);
expect(assign.request.method).toBe('PUT');
assign.flush(ada);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
http.expectOne(`${adminApi}/players?search=Linus&assignment=all&page=1&limit=20`).flush(playersPage());
});
it('cancels an older player load when a newer player search starts', () => {
isAdmin.set(true);
create();
flushDirectory();
button('Zuordnungen verwalten').click();
fixture.detectChanges();
const older = http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`);
const panel = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!;
const playerSearch = panel.querySelector<HTMLInputElement>('input[type="search"]')!;
playerSearch.value = 'New';
playerSearch.dispatchEvent(new Event('input'));
playerSearch.closest('form')!.dispatchEvent(new Event('submit'));
const newer = http.expectOne(`${adminApi}/players?search=New&assignment=all&page=1&limit=20`);
const olderWasCancelled = older.cancelled;
newer.flush(playersPage({ data: [{ ...playersPage().data[0], firstName: 'New' }] }));
if (!olderWasCancelled) older.flush(playersPage());
fixture.detectChanges();
expect(olderWasCancelled).toBe(true);
expect(panel.textContent).toContain('New Player');
expect(panel.textContent).not.toContain('Linus Player');
});
it('clears stale player rows on load errors and retries with load-specific copy', () => {
isAdmin.set(true); isAdmin.set(true);
create(); create();
flushDirectory(); flushDirectory();
@@ -253,15 +362,23 @@ describe('Users directory', () => {
fixture.detectChanges(); fixture.detectChanges();
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage()); http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
fixture.detectChanges(); fixture.detectChanges();
expect(text()).toContain('Linus Player'); const panel = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!;
expect(text()).toContain('Second Team'); expect(panel.textContent).toContain('Linus Player');
const playerSearch = panel.querySelector<HTMLInputElement>('input[type="search"]')!;
playerSearch.value = 'broken';
playerSearch.dispatchEvent(new Event('input'));
playerSearch.closest('form')!.dispatchEvent(new Event('submit'));
http.expectOne(`${adminApi}/players?search=broken&assignment=all&page=1&limit=20`).flush(
{ message: 'Search unavailable' },
{ status: 500, statusText: 'Server Error' },
);
fixture.detectChanges();
button('Zuordnen').click(); expect(panel.textContent).not.toContain('Linus Player');
const assign = http.expectOne(`${adminApi}/7/players/202`); expect(panel.textContent).toContain('Spieler konnten nicht geladen werden');
expect(assign.request.method).toBe('PUT'); expect(panel.textContent).toContain('Search unavailable');
assign.flush(ada); childButton(panel, 'Erneut versuchen').click();
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage()); http.expectOne(`${adminApi}/players?search=broken&assignment=all&page=1&limit=20`).flush(playersPage());
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
}); });
it('confirms unlinking and reassignment with the affected user names', () => { it('confirms unlinking and reassignment with the affected user names', () => {
@@ -341,11 +458,37 @@ describe('Users directory', () => {
const unlink = http.expectOne(`${adminApi}/7/players/101`); const unlink = http.expectOne(`${adminApi}/7/players/101`);
expect(unlink.request.method).toBe('DELETE'); expect(unlink.request.method).toBe('DELETE');
expect(text()).toContain('Wird gelöst'); expect(text()).toContain('Wird gelöst');
const panel = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!;
expect(panel.querySelector('section')?.getAttribute('aria-busy')).toBe('true');
expect(childButton(panel, 'Schließen').disabled).toBe(true);
expect(childButton(panel, 'Suchen').disabled).toBe(true);
const mainToggle = [...(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLButtonElement>('[data-user-id="7"] > .user-row__actions button')].find(
(item) => item.textContent?.includes('Zuordnungen verwalten'),
)!;
expect(mainToggle.disabled).toBe(true);
childButton(panel, 'Schließen').click();
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('app-player-assignments')).not.toBeNull();
unlink.flush(ada); unlink.flush(ada);
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage()); http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage()); http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
}); });
it('shows a persistent explanation for disabled self-deactivation', () => {
isAdmin.set(true);
currentUser.set({ id: 1, firstName: 'Grace', lastName: 'Admin', role: { id: 1 } });
create();
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage([admin]));
fixture.detectChanges();
const row = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('[data-user-id="1"]')!;
const statusButton = [...row.querySelectorAll('button')].find((item) => item.textContent?.includes('Deaktivieren'))!;
const descriptionId = statusButton.getAttribute('aria-describedby');
expect(descriptionId).toBeTruthy();
expect((fixture.nativeElement as HTMLElement).querySelector(`#${descriptionId}`)?.textContent).toContain(
'eigene Konto kann nicht deaktiviert werden',
);
});
it('renders loading, empty, general error, and retry states', () => { it('renders loading, empty, general error, and retry states', () => {
create(); create();
expect((fixture.nativeElement as HTMLElement).querySelector('[role="progressbar"]')).not.toBeNull(); expect((fixture.nativeElement as HTMLElement).querySelector('[role="progressbar"]')).not.toBeNull();

View File

@@ -1,5 +1,6 @@
import { HttpErrorResponse } from '@angular/common/http'; import { HttpErrorResponse } from '@angular/common/http';
import { Component, inject, signal } from '@angular/core'; import { Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
@@ -7,7 +8,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { filter, finalize, of, switchMap, take } from 'rxjs'; import { EMPTY, Subject, catchError, filter, finalize, of, startWith, switchMap, take, tap } from 'rxjs';
import { AuthStore } from '../../core/auth/auth-store'; import { AuthStore } from '../../core/auth/auth-store';
import { AdminUsersApi } from '../../core/users/admin-users-api'; import { AdminUsersApi } from '../../core/users/admin-users-api';
import { UsersApi } from '../../core/users/users-api'; import { UsersApi } from '../../core/users/users-api';
@@ -41,6 +42,8 @@ export class Users {
private readonly adminUsersApi = inject(AdminUsersApi); private readonly adminUsersApi = inject(AdminUsersApi);
private readonly authStore = inject(AuthStore); private readonly authStore = inject(AuthStore);
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
private readonly destroyRef = inject(DestroyRef);
private readonly directoryRequests = new Subject<boolean>();
protected readonly isAdmin = this.authStore.isGlobalAdmin; protected readonly isAdmin = this.authStore.isGlobalAdmin;
protected readonly currentUser = this.authStore.currentUser; protected readonly currentUser = this.authStore.currentUser;
@@ -54,30 +57,42 @@ export class Users {
protected readonly limit = 20; protected readonly limit = 20;
protected readonly editingUserId = signal<number | null>(null); protected readonly editingUserId = signal<number | null>(null);
protected readonly assignmentUserId = signal<number | null>(null); protected readonly assignmentUserId = signal<number | null>(null);
protected readonly assignmentBusyUserId = signal<number | null>(null);
protected readonly pendingUserId = signal<number | null>(null); protected readonly pendingUserId = signal<number | null>(null);
constructor() { constructor() {
this.loadDirectory(); this.directoryRequests
.pipe(
startWith(true),
switchMap((showLoading) => {
if (showLoading) this.loading.set(true);
this.loadError.set(null);
const search = this.search();
return this.usersApi
.loadDirectory({ page: this.page(), limit: this.limit, ...(search ? { search } : {}) })
.pipe(
tap((directory) => this.directory.set(directory)),
catchError((error: HttpErrorResponse) => {
this.directory.set(null);
this.loadError.set(this.errorMessage(error, 'Benutzer konnten nicht geladen werden.'));
return EMPTY;
}),
finalize(() => this.loading.set(false)),
);
}),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
} }
protected loadDirectory(): void { protected loadDirectory(showLoading = true): void {
this.loading.set(true); this.directoryRequests.next(showLoading);
this.loadError.set(null);
const search = this.search();
this.usersApi
.loadDirectory({ page: this.page(), limit: this.limit, ...(search ? { search } : {}) })
.pipe(finalize(() => this.loading.set(false)))
.subscribe({
next: (directory) => this.directory.set(directory),
error: (error: HttpErrorResponse) => {
this.directory.set(null);
this.loadError.set(this.errorMessage(error, 'Benutzer konnten nicht geladen werden.'));
},
});
} }
protected submitSearch(): void { protected submitSearch(): void {
this.search.set(this.searchDraft().trim()); const search = this.searchDraft().trim();
if (this.loading() && search === this.search() && this.page() === 1) return;
this.search.set(search);
this.page.set(1); this.page.set(1);
this.loadDirectory(); this.loadDirectory();
} }
@@ -129,12 +144,14 @@ export class Users {
} }
protected toggleEdit(userId: number): void { protected toggleEdit(userId: number): void {
if (this.assignmentBusyUserId() !== null) return;
this.assignmentUserId.set(null); this.assignmentUserId.set(null);
this.editingUserId.update((value) => (value === userId ? null : userId)); this.editingUserId.update((value) => (value === userId ? null : userId));
this.mutationError.set(null); this.mutationError.set(null);
} }
protected toggleAssignments(userId: number): void { protected toggleAssignments(userId: number): void {
if (this.assignmentBusyUserId() !== null) return;
this.editingUserId.set(null); this.editingUserId.set(null);
this.assignmentUserId.update((value) => (value === userId ? null : userId)); this.assignmentUserId.update((value) => (value === userId ? null : userId));
this.mutationError.set(null); this.mutationError.set(null);
@@ -149,12 +166,15 @@ export class Users {
lastName: value.lastName, lastName: value.lastName,
}); });
const roleId = user.role?.id; const roleId = user.role?.id;
let profileSaved = false;
profileRequest profileRequest
.pipe( .pipe(
tap(() => (profileSaved = true)),
switchMap(() => switchMap(() =>
roleId === value.role ? of(user) : this.adminUsersApi.updateRole(user.id, { role: value.role }), roleId === value.role ? of(user) : this.adminUsersApi.updateRole(user.id, { role: value.role }),
), ),
finalize(() => this.pendingUserId.set(null)), finalize(() => this.pendingUserId.set(null)),
takeUntilDestroyed(this.destroyRef),
) )
.subscribe({ .subscribe({
next: () => { next: () => {
@@ -163,7 +183,7 @@ export class Users {
}, },
error: (error: HttpErrorResponse) => { error: (error: HttpErrorResponse) => {
this.mutationError.set(this.errorMessage(error, 'Änderung fehlgeschlagen.')); this.mutationError.set(this.errorMessage(error, 'Änderung fehlgeschlagen.'));
this.loadDirectory(); if (profileSaved) this.loadDirectory();
}, },
}); });
} }
@@ -182,7 +202,7 @@ export class Users {
restoreFocus: true, restoreFocus: true,
}) })
.afterClosed() .afterClosed()
.pipe(filter(Boolean), take(1)) .pipe(filter(Boolean), take(1), takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.updateStatus(user.id, status)); .subscribe(() => this.updateStatus(user.id, status));
} }
@@ -191,7 +211,11 @@ export class Users {
} }
protected assignmentsChanged(): void { protected assignmentsChanged(): void {
this.loadDirectory(); this.loadDirectory(false);
}
protected assignmentBusyChanged(userId: number, busy: boolean): void {
this.assignmentBusyUserId.set(busy ? userId : null);
} }
private updateStatus(userId: number, status: AdminUserStatusId): void { private updateStatus(userId: number, status: AdminUserStatusId): void {
@@ -199,7 +223,7 @@ export class Users {
this.mutationError.set(null); this.mutationError.set(null);
this.adminUsersApi this.adminUsersApi
.updateStatus(userId, { status }) .updateStatus(userId, { status })
.pipe(finalize(() => this.pendingUserId.set(null))) .pipe(finalize(() => this.pendingUserId.set(null)), takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: () => this.loadDirectory(), next: () => this.loadDirectory(),
error: (error: HttpErrorResponse) => error: (error: HttpErrorResponse) =>