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 { 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;

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 { 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 {

View File

@@ -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)"
/>
}
}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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();
}
protected loadDirectory(): void {
this.loading.set(true);
this.directoryRequests
.pipe(
startWith(true),
switchMap((showLoading) => {
if (showLoading) this.loading.set(true);
this.loadError.set(null);
const search = this.search();
this.usersApi
return 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) => {
.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(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) =>