fix: stabilize admin user UI state
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
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 { MatDialog } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
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 {
|
||||
AdminPlayerPage,
|
||||
@@ -25,34 +26,51 @@ import { ConfirmDialog } from '../../shared/confirm-dialog/confirm-dialog';
|
||||
MatProgressSpinnerModule,
|
||||
],
|
||||
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>
|
||||
<h3>Spielerzuordnungen</h3>
|
||||
<p>Spieler teamübergreifend suchen und sicher verknüpfen.</p>
|
||||
</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>
|
||||
|
||||
<form class="assignments__search" (submit)="submitSearch(); $event.preventDefault()" role="search">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Spieler suchen</mat-label>
|
||||
<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>
|
||||
<button mat-stroked-button type="submit">Suchen</button>
|
||||
<button mat-stroked-button type="submit" [disabled]="pendingPlayerId() !== null">Suchen</button>
|
||||
</form>
|
||||
|
||||
@if (error()) {
|
||||
@if (loadError()) {
|
||||
<div class="assignments__error" role="alert">
|
||||
<span>{{ error() }}</span>
|
||||
<span>{{ loadError() }}</span>
|
||||
<button mat-button type="button" (click)="loadPlayers()">Erneut versuchen</button>
|
||||
</div>
|
||||
}
|
||||
@if (mutationError()) {
|
||||
<div class="assignments__error" role="alert">{{ mutationError() }}</div>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="assignments__state"><mat-spinner diameter="30" /></div>
|
||||
} @else if (!error() && players()?.data?.length === 0) {
|
||||
<div class="assignments__state" aria-live="polite">
|
||||
<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>
|
||||
} @else if (players()) {
|
||||
<div class="player-list">
|
||||
@@ -98,15 +116,15 @@ import { ConfirmDialog } from '../../shared/confirm-dialog/confirm-dialog';
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="assignments__paging" aria-label="Spielerseiten">
|
||||
<button mat-button type="button" [disabled]="players()!.page <= 1 || loading()" (click)="previousPage()">
|
||||
<nav class="assignments__paging" aria-label="Spielerseiten">
|
||||
<button mat-button type="button" [disabled]="players()!.page <= 1 || loading() || pendingPlayerId() !== null" (click)="previousPage()">
|
||||
<mat-icon>chevron_left</mat-icon>Zurück
|
||||
</button>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
@@ -135,21 +153,58 @@ import { ConfirmDialog } from '../../shared/confirm-dialog/confirm-dialog';
|
||||
export class PlayerAssignments implements OnChanges {
|
||||
private readonly adminUsersApi = inject(AdminUsersApi);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly playerRequests = new Subject<void>();
|
||||
|
||||
@Input({ required: true }) user!: AdminUserDirectorySummary;
|
||||
@Output() readonly directoryChanged = new EventEmitter<void>();
|
||||
@Output() readonly closed = new EventEmitter<void>();
|
||||
@Output() readonly busyChange = new EventEmitter<boolean>();
|
||||
|
||||
protected readonly players = signal<AdminPlayerPage | null>(null);
|
||||
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 searchDraft = '';
|
||||
private search = '';
|
||||
private page = 1;
|
||||
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.search = '';
|
||||
this.page = 1;
|
||||
@@ -157,36 +212,26 @@ export class PlayerAssignments implements OnChanges {
|
||||
}
|
||||
|
||||
protected loadPlayers(): void {
|
||||
this.loading.set(true);
|
||||
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)),
|
||||
});
|
||||
this.playerRequests.next();
|
||||
}
|
||||
|
||||
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.loadPlayers();
|
||||
}
|
||||
|
||||
protected previousPage(): void {
|
||||
if (this.page <= 1 || this.loading()) return;
|
||||
if (this.page <= 1 || this.loading() || this.pendingPlayerId() !== null) return;
|
||||
this.page -= 1;
|
||||
this.loadPlayers();
|
||||
}
|
||||
|
||||
protected nextPage(): void {
|
||||
if (!this.players()?.hasNextPage || this.loading()) return;
|
||||
if (!this.players()?.hasNextPage || this.loading() || this.pendingPlayerId() !== null) return;
|
||||
this.page += 1;
|
||||
this.loadPlayers();
|
||||
}
|
||||
@@ -195,6 +240,10 @@ export class PlayerAssignments implements OnChanges {
|
||||
this.runAssignment(player);
|
||||
}
|
||||
|
||||
protected close(): void {
|
||||
if (this.pendingPlayerId() === null) this.closed.emit();
|
||||
}
|
||||
|
||||
protected confirmUnlink(player: AdminPlayerSummary): void {
|
||||
this.confirm({
|
||||
title: 'Verknüpfung lösen?',
|
||||
@@ -223,32 +272,32 @@ export class PlayerAssignments implements OnChanges {
|
||||
return this.dialog
|
||||
.open(ConfirmDialog, { data, restoreFocus: true })
|
||||
.afterClosed()
|
||||
.pipe(filter(Boolean), take(1));
|
||||
.pipe(filter(Boolean), take(1), takeUntilDestroyed(this.destroyRef));
|
||||
}
|
||||
|
||||
private runAssignment(player: AdminPlayerSummary): void {
|
||||
if (this.pendingPlayerId() !== null) return;
|
||||
this.pendingPlayerId.set(player.id);
|
||||
this.error.set(null);
|
||||
this.setPending(player.id);
|
||||
this.mutationError.set(null);
|
||||
this.adminUsersApi
|
||||
.assignPlayer(this.user.id, player.id)
|
||||
.pipe(finalize(() => this.pendingPlayerId.set(null)))
|
||||
.pipe(finalize(() => this.setPending(null)), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
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 {
|
||||
if (this.pendingPlayerId() !== null) return;
|
||||
this.pendingPlayerId.set(player.id);
|
||||
this.error.set(null);
|
||||
this.setPending(player.id);
|
||||
this.mutationError.set(null);
|
||||
this.adminUsersApi
|
||||
.unlinkPlayer(this.user.id, player.id)
|
||||
.pipe(finalize(() => this.pendingPlayerId.set(null)))
|
||||
.pipe(finalize(() => this.setPending(null)), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
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();
|
||||
}
|
||||
|
||||
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 fallback = error.status === 403 ? 'Keine Berechtigung.' : 'Zuordnung konnte nicht geändert werden.';
|
||||
return detail ? `${fallback} ${detail}` : fallback;
|
||||
|
||||
@@ -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 { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
@@ -66,10 +66,23 @@ export class UserEdit implements OnChanges {
|
||||
protected lastName = '';
|
||||
protected role: AdminUserRoleId = 2;
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.firstName = this.user.firstName ?? '';
|
||||
this.lastName = this.user.lastName ?? '';
|
||||
this.role = this.user.role?.id === 1 ? 1 : 2;
|
||||
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 &&
|
||||
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 {
|
||||
|
||||
@@ -45,7 +45,12 @@
|
||||
} @else if (directory()) {
|
||||
<div class="directory" aria-live="polite">
|
||||
@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="avatar" aria-hidden="true">{{ initials(user) }}</div>
|
||||
<div class="user-row__identity">
|
||||
@@ -76,18 +81,39 @@
|
||||
|
||||
@if (adminDetails(user); as details) {
|
||||
<div class="user-row__actions">
|
||||
<button mat-button type="button" (click)="toggleEdit(user.id)">Bearbeiten</button>
|
||||
<button mat-button type="button" (click)="toggleAssignments(user.id)">Zuordnungen verwalten</button>
|
||||
<button mat-button type="button" [disabled]="assignmentBusyUserId() !== null" (click)="toggleEdit(user.id)">
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="assignmentBusyUserId() !== null"
|
||||
(click)="toggleAssignments(user.id)"
|
||||
>
|
||||
Zuordnungen verwalten
|
||||
</button>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
[disabled]="isSelf(user) || pendingUserId() !== 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)"
|
||||
>
|
||||
{{ user.status?.id === 2 ? 'Aktivieren' : 'Deaktivieren' }}
|
||||
{{
|
||||
pendingUserId() === user.id
|
||||
? 'Status wird geändert …'
|
||||
: user.status?.id === 2
|
||||
? 'Aktivieren'
|
||||
: 'Deaktivieren'
|
||||
}}
|
||||
</button>
|
||||
</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) {
|
||||
<app-user-edit
|
||||
@@ -103,6 +129,7 @@
|
||||
[user]="details"
|
||||
(directoryChanged)="assignmentsChanged()"
|
||||
(closed)="assignmentUserId.set(null)"
|
||||
(busyChange)="assignmentBusyChanged(user.id, $event)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,13 @@ h1 {
|
||||
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 {
|
||||
min-height: 240px;
|
||||
display: grid;
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('Users directory', () => {
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => http.verify());
|
||||
afterEach(() => http.verify({ ignoreCancelled: true }));
|
||||
|
||||
function create(): void {
|
||||
fixture = TestBed.createComponent(Users);
|
||||
@@ -113,6 +113,13 @@ describe('Users directory', () => {
|
||||
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 {
|
||||
http.expectOne(`${api}?page=1&limit=20`).flush(page);
|
||||
fixture.detectChanges();
|
||||
@@ -147,6 +154,25 @@ describe('Users directory', () => {
|
||||
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', () => {
|
||||
create();
|
||||
flushDirectory(directoryPage([ada]));
|
||||
@@ -208,6 +234,31 @@ describe('Users directory', () => {
|
||||
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', () => {
|
||||
isAdmin.set(true);
|
||||
create();
|
||||
@@ -241,11 +292,69 @@ describe('Users directory', () => {
|
||||
closeDialog.next(true);
|
||||
const request = http.expectOne(`${adminApi}/7/status`);
|
||||
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);
|
||||
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);
|
||||
create();
|
||||
flushDirectory();
|
||||
@@ -253,15 +362,23 @@ describe('Users directory', () => {
|
||||
fixture.detectChanges();
|
||||
http.expectOne(`${adminApi}/players?assignment=all&page=1&limit=20`).flush(playersPage());
|
||||
fixture.detectChanges();
|
||||
expect(text()).toContain('Linus Player');
|
||||
expect(text()).toContain('Second Team');
|
||||
const panel = (fixture.nativeElement as HTMLElement).querySelector<HTMLElement>('app-player-assignments')!;
|
||||
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();
|
||||
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?assignment=all&page=1&limit=20`).flush(playersPage());
|
||||
expect(panel.textContent).not.toContain('Linus Player');
|
||||
expect(panel.textContent).toContain('Spieler konnten nicht geladen werden');
|
||||
expect(panel.textContent).toContain('Search unavailable');
|
||||
childButton(panel, 'Erneut versuchen').click();
|
||||
http.expectOne(`${adminApi}/players?search=broken&assignment=all&page=1&limit=20`).flush(playersPage());
|
||||
});
|
||||
|
||||
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`);
|
||||
expect(unlink.request.method).toBe('DELETE');
|
||||
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);
|
||||
http.expectOne(`${api}?page=1&limit=20`).flush(directoryPage());
|
||||
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', () => {
|
||||
create();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('[role="progressbar"]')).not.toBeNull();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
@@ -7,7 +8,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
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 { AdminUsersApi } from '../../core/users/admin-users-api';
|
||||
import { UsersApi } from '../../core/users/users-api';
|
||||
@@ -41,6 +42,8 @@ export class Users {
|
||||
private readonly adminUsersApi = inject(AdminUsersApi);
|
||||
private readonly authStore = inject(AuthStore);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly directoryRequests = new Subject<boolean>();
|
||||
|
||||
protected readonly isAdmin = this.authStore.isGlobalAdmin;
|
||||
protected readonly currentUser = this.authStore.currentUser;
|
||||
@@ -54,30 +57,42 @@ export class Users {
|
||||
protected readonly limit = 20;
|
||||
protected readonly editingUserId = 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);
|
||||
|
||||
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 {
|
||||
this.loading.set(true);
|
||||
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 loadDirectory(showLoading = true): void {
|
||||
this.directoryRequests.next(showLoading);
|
||||
}
|
||||
|
||||
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.loadDirectory();
|
||||
}
|
||||
@@ -129,12 +144,14 @@ export class Users {
|
||||
}
|
||||
|
||||
protected toggleEdit(userId: number): void {
|
||||
if (this.assignmentBusyUserId() !== null) return;
|
||||
this.assignmentUserId.set(null);
|
||||
this.editingUserId.update((value) => (value === userId ? null : userId));
|
||||
this.mutationError.set(null);
|
||||
}
|
||||
|
||||
protected toggleAssignments(userId: number): void {
|
||||
if (this.assignmentBusyUserId() !== null) return;
|
||||
this.editingUserId.set(null);
|
||||
this.assignmentUserId.update((value) => (value === userId ? null : userId));
|
||||
this.mutationError.set(null);
|
||||
@@ -149,12 +166,15 @@ export class Users {
|
||||
lastName: value.lastName,
|
||||
});
|
||||
const roleId = user.role?.id;
|
||||
let profileSaved = false;
|
||||
profileRequest
|
||||
.pipe(
|
||||
tap(() => (profileSaved = true)),
|
||||
switchMap(() =>
|
||||
roleId === value.role ? of(user) : this.adminUsersApi.updateRole(user.id, { role: value.role }),
|
||||
),
|
||||
finalize(() => this.pendingUserId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
@@ -163,7 +183,7 @@ export class Users {
|
||||
},
|
||||
error: (error: HttpErrorResponse) => {
|
||||
this.mutationError.set(this.errorMessage(error, 'Änderung fehlgeschlagen.'));
|
||||
this.loadDirectory();
|
||||
if (profileSaved) this.loadDirectory();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -182,7 +202,7 @@ export class Users {
|
||||
restoreFocus: true,
|
||||
})
|
||||
.afterClosed()
|
||||
.pipe(filter(Boolean), take(1))
|
||||
.pipe(filter(Boolean), take(1), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.updateStatus(user.id, status));
|
||||
}
|
||||
|
||||
@@ -191,7 +211,11 @@ export class Users {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -199,7 +223,7 @@ export class Users {
|
||||
this.mutationError.set(null);
|
||||
this.adminUsersApi
|
||||
.updateStatus(userId, { status })
|
||||
.pipe(finalize(() => this.pendingUserId.set(null)))
|
||||
.pipe(finalize(() => this.pendingUserId.set(null)), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => this.loadDirectory(),
|
||||
error: (error: HttpErrorResponse) =>
|
||||
|
||||
Reference in New Issue
Block a user