From 9dc9dc3dcf2590e67df0e86b8b6bf1b032e4eb98 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 1 Aug 2026 10:32:51 +0200 Subject: [PATCH] fix: stabilize admin user UI state --- .../app/features/users/player-assignments.ts | 146 +++++++++++----- .../src/app/features/users/user-edit.ts | 23 ++- .../src/app/features/users/users.html | 35 +++- .../src/app/features/users/users.scss | 7 + .../src/app/features/users/users.spec.ts | 163 ++++++++++++++++-- .../src/app/features/users/users.ts | 68 +++++--- 6 files changed, 358 insertions(+), 84 deletions(-) diff --git a/myteamwallet_frontend_modern/src/app/features/users/player-assignments.ts b/myteamwallet_frontend_modern/src/app/features/users/player-assignments.ts index 697eaa7..c614a79 100644 --- a/myteamwallet_frontend_modern/src/app/features/users/player-assignments.ts +++ b/myteamwallet_frontend_modern/src/app/features/users/player-assignments.ts @@ -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: ` -
+

Spielerzuordnungen

Spieler teamübergreifend suchen und sicher verknüpfen.

- +
- @if (error()) { + @if (loadError()) { } + @if (mutationError()) { + + } @if (loading()) { -
- } @else if (!error() && players()?.data?.length === 0) { +
+ + Spieler werden geladen … +
+ } @else if (!loadError() && players()?.data?.length === 0) {
Keine Spieler gefunden.
} @else if (players()) {
@@ -98,15 +116,15 @@ import { ConfirmDialog } from '../../shared/confirm-dialog/confirm-dialog';
} -
- Seite {{ players()!.page }} - -
+ }
`, @@ -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(); @Input({ required: true }) user!: AdminUserDirectorySummary; @Output() readonly directoryChanged = new EventEmitter(); @Output() readonly closed = new EventEmitter(); + @Output() readonly busyChange = new EventEmitter(); protected readonly players = signal(null); protected readonly loading = signal(false); - protected readonly error = signal(null); + protected readonly loadError = signal(null); + protected readonly mutationError = signal(null); protected readonly pendingPlayerId = signal(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; diff --git a/myteamwallet_frontend_modern/src/app/features/users/user-edit.ts b/myteamwallet_frontend_modern/src/app/features/users/user-edit.ts index e572592..3a1e3bd 100644 --- a/myteamwallet_frontend_modern/src/app/features/users/user-edit.ts +++ b/myteamwallet_frontend_modern/src/app/features/users/user-edit.ts @@ -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 { diff --git a/myteamwallet_frontend_modern/src/app/features/users/users.html b/myteamwallet_frontend_modern/src/app/features/users/users.html index 7d46401..c04da1c 100644 --- a/myteamwallet_frontend_modern/src/app/features/users/users.html +++ b/myteamwallet_frontend_modern/src/app/features/users/users.html @@ -45,7 +45,12 @@ } @else if (directory()) {
@for (user of directory()!.data; track user.id) { -
+
@@ -76,18 +81,39 @@ @if (adminDetails(user); as details) {
- - + +
+ @if (isSelf(user)) { +

+ Das eigene Konto kann nicht deaktiviert werden. +

+ } @if (editingUserId() === user.id) { } } diff --git a/myteamwallet_frontend_modern/src/app/features/users/users.scss b/myteamwallet_frontend_modern/src/app/features/users/users.scss index 730f9ce..f4b80fa 100644 --- a/myteamwallet_frontend_modern/src/app/features/users/users.scss +++ b/myteamwallet_frontend_modern/src/app/features/users/users.scss @@ -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; diff --git a/myteamwallet_frontend_modern/src/app/features/users/users.spec.ts b/myteamwallet_frontend_modern/src/app/features/users/users.spec.ts index c65211a..fa7e367 100644 --- a/myteamwallet_frontend_modern/src/app/features/users/users.spec.ts +++ b/myteamwallet_frontend_modern/src/app/features/users/users.spec.ts @@ -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('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('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('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('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('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('app-player-assignments')!; + const playerSearch = panel.querySelector('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('app-player-assignments')!; + expect(panel.textContent).toContain('Linus Player'); + const playerSearch = panel.querySelector('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('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('[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('[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(); diff --git a/myteamwallet_frontend_modern/src/app/features/users/users.ts b/myteamwallet_frontend_modern/src/app/features/users/users.ts index bc9f6e5..47e8278 100644 --- a/myteamwallet_frontend_modern/src/app/features/users/users.ts +++ b/myteamwallet_frontend_modern/src/app/features/users/users.ts @@ -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(); 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(null); protected readonly assignmentUserId = signal(null); + protected readonly assignmentBusyUserId = signal(null); protected readonly pendingUserId = signal(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) =>