fix: address penalty catalog review findings
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
<input matInput type="number" min="0.01" max="10000" step="0.01" formControlName="amount" />
|
||||
<span matTextSuffix>€</span>
|
||||
</mat-form-field>
|
||||
<button mat-flat-button type="submit" [disabled]="form.invalid || saving()">
|
||||
<button mat-flat-button type="submit" [disabled]="form.invalid || mutationPending()">
|
||||
@if (saving()) {
|
||||
<mat-spinner diameter="18" />
|
||||
} @else {
|
||||
@@ -128,7 +128,7 @@
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="startEdit(penalty)"
|
||||
[disabled]="pendingPenaltyId() !== null"
|
||||
[disabled]="mutationPending()"
|
||||
[attr.aria-label]="penalty.description + ' bearbeiten'"
|
||||
>
|
||||
<mat-icon>edit</mat-icon>Bearbeiten
|
||||
@@ -137,7 +137,7 @@
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="confirmDelete(penalty)"
|
||||
[disabled]="pendingPenaltyId() !== null"
|
||||
[disabled]="mutationPending()"
|
||||
[attr.aria-label]="penalty.description + ' löschen'"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>Löschen
|
||||
|
||||
@@ -190,6 +190,70 @@ describe('Penalties', () => {
|
||||
expect(text()).toContain('existiert bereits');
|
||||
});
|
||||
|
||||
it('distinguishes a successful update from a failed authoritative reload', () => {
|
||||
loadPenalties
|
||||
.mockReturnValueOnce(of([first]))
|
||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 500 })));
|
||||
create();
|
||||
button('Bearbeiten').click();
|
||||
fixture.componentInstance['editForm'].setValue({ description: 'Neu', amount: 6 });
|
||||
|
||||
fixture.componentInstance['savePenalty'](first);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance['editingPenaltyId']()).toBeNull();
|
||||
expect(fixture.componentInstance['mutationError']()).toBeNull();
|
||||
expect(text()).toContain('Änderung wurde gespeichert');
|
||||
expect(text()).toContain('Erneut versuchen');
|
||||
});
|
||||
|
||||
it('clears the create form after success even when the reload fails', () => {
|
||||
loadPenalties
|
||||
.mockReturnValueOnce(of([first]))
|
||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 500 })));
|
||||
create();
|
||||
fixture.componentInstance['form'].setValue({ description: 'Handy', amount: 3 });
|
||||
|
||||
fixture.componentInstance['createPenalty']();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance['form'].getRawValue()).toEqual({
|
||||
description: '',
|
||||
amount: 0,
|
||||
});
|
||||
expect(fixture.componentInstance['mutationError']()).toBeNull();
|
||||
expect(text()).toContain('Änderung wurde gespeichert');
|
||||
});
|
||||
|
||||
it('reports a reload problem instead of a delete failure after successful deletion', () => {
|
||||
loadPenalties
|
||||
.mockReturnValueOnce(of([first]))
|
||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 500 })));
|
||||
create();
|
||||
|
||||
button('Löschen').click();
|
||||
dialogClosed.next(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(deletePenalty).toHaveBeenCalledWith(first.id);
|
||||
expect(fixture.componentInstance['mutationError']()).toBeNull();
|
||||
expect(text()).toContain('Änderung wurde gespeichert');
|
||||
});
|
||||
|
||||
it('prevents overlapping catalog mutations', () => {
|
||||
const creation = new Subject<Penalty>();
|
||||
createPenalty.mockReturnValue(creation);
|
||||
create();
|
||||
fixture.componentInstance['form'].setValue({ description: 'Handy', amount: 3 });
|
||||
|
||||
fixture.componentInstance['createPenalty']();
|
||||
fixture.componentInstance['startEdit'](first);
|
||||
fixture.componentInstance['confirmDelete'](first);
|
||||
|
||||
expect(fixture.componentInstance['editingPenaltyId']()).toBeNull();
|
||||
expect(dialog.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a load error, retries, and distinguishes an empty search result', () => {
|
||||
loadPenalties
|
||||
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 500 })))
|
||||
|
||||
@@ -12,7 +12,7 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { finalize, switchMap, take } from 'rxjs';
|
||||
import { EMPTY, Observable, catchError, finalize, switchMap, take, tap } from 'rxjs';
|
||||
import { AuthStore } from '../../../../core/auth/auth-store';
|
||||
import { PenaltyApi } from '../../../../core/team/penalty-api';
|
||||
import { TeamStore } from '../../../../core/team/team-store';
|
||||
@@ -55,6 +55,9 @@ export class Penalties {
|
||||
protected readonly editingPenaltyId = signal<number | null>(null);
|
||||
protected readonly loadError = signal<string | null>(null);
|
||||
protected readonly mutationError = signal<string | null>(null);
|
||||
protected readonly mutationPending = computed(
|
||||
() => this.saving() || this.pendingPenaltyId() !== null,
|
||||
);
|
||||
protected readonly search = signal('');
|
||||
protected readonly form = this.formBuilder.nonNullable.group({
|
||||
description: ['', [Validators.required, Validators.maxLength(120), Validators.pattern(/\S/)]],
|
||||
@@ -109,20 +112,20 @@ export class Penalties {
|
||||
|
||||
protected createPenalty(): void {
|
||||
const team = this.team();
|
||||
if (!this.canManage() || !team || this.form.invalid || this.saving()) return;
|
||||
if (!this.canManage() || !team || this.form.invalid || this.mutationPending()) return;
|
||||
this.saving.set(true);
|
||||
this.mutationError.set(null);
|
||||
this.penaltyApi
|
||||
.createPenalty({ teamId: team.id, ...this.form.getRawValue() })
|
||||
.pipe(
|
||||
switchMap(() => this.penaltyApi.loadPenalties(team.id)),
|
||||
tap(() => this.form.reset({ description: '', amount: 0 })),
|
||||
switchMap(() => this.reloadAfterMutation(team.id)),
|
||||
finalize(() => this.saving.set(false)),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe({
|
||||
next: (penalties) => {
|
||||
this.penalties.set(penalties);
|
||||
this.form.reset({ description: '', amount: 0 });
|
||||
},
|
||||
error: (error: HttpErrorResponse) =>
|
||||
this.mutationError.set(this.errorMessage(error, 'Eintrag konnte nicht angelegt werden.')),
|
||||
@@ -130,7 +133,7 @@ export class Penalties {
|
||||
}
|
||||
|
||||
protected startEdit(penalty: Penalty): void {
|
||||
if (!this.canManage() || this.pendingPenaltyId() !== null) return;
|
||||
if (!this.canManage() || this.mutationPending()) return;
|
||||
this.editingPenaltyId.set(penalty.id);
|
||||
this.editForm.setValue({ description: penalty.description, amount: penalty.amount });
|
||||
this.mutationError.set(null);
|
||||
@@ -149,7 +152,7 @@ export class Penalties {
|
||||
!teamId ||
|
||||
this.editingPenaltyId() !== penalty.id ||
|
||||
this.editForm.invalid ||
|
||||
this.pendingPenaltyId() !== null
|
||||
this.mutationPending()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -158,14 +161,14 @@ export class Penalties {
|
||||
this.penaltyApi
|
||||
.updatePenalty(penalty.id, this.editForm.getRawValue())
|
||||
.pipe(
|
||||
switchMap(() => this.penaltyApi.loadPenalties(teamId)),
|
||||
tap(() => this.editingPenaltyId.set(null)),
|
||||
switchMap(() => this.reloadAfterMutation(teamId)),
|
||||
finalize(() => this.pendingPenaltyId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe({
|
||||
next: (penalties) => {
|
||||
this.penalties.set(penalties);
|
||||
this.editingPenaltyId.set(null);
|
||||
},
|
||||
error: (error: HttpErrorResponse) =>
|
||||
this.mutationError.set(
|
||||
@@ -175,7 +178,7 @@ export class Penalties {
|
||||
}
|
||||
|
||||
protected confirmDelete(penalty: Penalty): void {
|
||||
if (!this.canManage() || this.pendingPenaltyId() !== null) return;
|
||||
if (!this.canManage() || this.mutationPending()) return;
|
||||
this.dialog
|
||||
.open(ConfirmDialog, {
|
||||
data: {
|
||||
@@ -205,7 +208,7 @@ export class Penalties {
|
||||
this.penaltyApi
|
||||
.deletePenalty(penalty.id)
|
||||
.pipe(
|
||||
switchMap(() => this.penaltyApi.loadPenalties(teamId)),
|
||||
switchMap(() => this.reloadAfterMutation(teamId)),
|
||||
finalize(() => this.pendingPenaltyId.set(null)),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
@@ -219,6 +222,17 @@ export class Penalties {
|
||||
});
|
||||
}
|
||||
|
||||
private reloadAfterMutation(teamId: number): Observable<Penalty[]> {
|
||||
return this.penaltyApi.loadPenalties(teamId).pipe(
|
||||
catchError(() => {
|
||||
this.loadError.set(
|
||||
'Änderung wurde gespeichert, aber der Katalog konnte nicht aktualisiert werden.',
|
||||
);
|
||||
return EMPTY;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private load(teamId: number): void {
|
||||
this.loading.set(true);
|
||||
this.loadError.set(null);
|
||||
|
||||
Reference in New Issue
Block a user