fix: stabilize admin user UI state
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user