fix: no error feedback on failed save/delete/role-change, editors navigated away before save result was known
Products/Categories saveDraft() and deleteOne(), and Users setRole()/setStatus()/invite(), had no error handler at all - a failed mutation was completely silent. Also found and fixed the same premature-navigation bug as the Phase 1 cart fix: both product and category editor pages called router.navigate() immediately after facade.saveDraft(), before the save had resolved - so even after adding error feedback, the user would already be gone from the page before it could show. saveDraft() now takes an onSuccess callback and only the page navigates on actual success; on failure it stays put and shows a themed error dialog. Added a mutationError signal to all three facades and a themed app-dialog error alert on the products/categories editor + list pages and the users page. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -137,6 +137,7 @@ export class AdminCategoriesFacade {
|
||||
readonly dirty = signal(false);
|
||||
readonly slugTaken = signal(false);
|
||||
readonly slugCheckError = signal(false);
|
||||
readonly mutationError = signal<string | null>(null);
|
||||
private savedSnapshot: string | null = null;
|
||||
private draftStorageKey: string | null = null;
|
||||
|
||||
@@ -326,10 +327,11 @@ export class AdminCategoriesFacade {
|
||||
});
|
||||
}
|
||||
|
||||
saveDraft(publish: boolean): void {
|
||||
saveDraft(publish: boolean, onSuccess?: () => void): void {
|
||||
const draft = this.draft();
|
||||
if (!draft || this.slugTaken() || this.slugCheckError()) return;
|
||||
|
||||
this.mutationError.set(null);
|
||||
const toSave: AdminCategory = { ...draft, status: publish ? 'published' : 'draft', updatedAt: new Date().toISOString() };
|
||||
const request = this.editorMode() === 'create' ? this.gateway.createCategory(toSave) : this.gateway.updateCategory(toSave);
|
||||
|
||||
@@ -342,7 +344,9 @@ export class AdminCategoriesFacade {
|
||||
this.savedSnapshot = JSON.stringify(saved);
|
||||
this.dirty.set(false);
|
||||
this.loadList();
|
||||
}
|
||||
onSuccess?.();
|
||||
},
|
||||
error: () => this.mutationError.set('common.errorDescription')
|
||||
});
|
||||
}
|
||||
|
||||
@@ -357,7 +361,11 @@ export class AdminCategoriesFacade {
|
||||
}
|
||||
|
||||
deleteOne(id: string): void {
|
||||
this.gateway.deleteCategory(id).pipe(take(1)).subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } });
|
||||
this.mutationError.set(null);
|
||||
this.gateway.deleteCategory(id).pipe(take(1)).subscribe({
|
||||
next: () => { this.loadList(); this.loadDashboardStats(); },
|
||||
error: () => this.mutationError.set('common.errorDescription')
|
||||
});
|
||||
}
|
||||
|
||||
restoreOne(id: string): void {
|
||||
|
||||
@@ -71,6 +71,14 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
<app-button variant="primary" (click)="deleteBlockedMessage.set(null)">{{ 'common.confirm' | translate }}</app-button>
|
||||
</div>
|
||||
</app-dialog>
|
||||
}
|
||||
@if (facade.mutationError()) {
|
||||
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
|
||||
<p>{{ 'common.errorDescription' | translate }}</p>
|
||||
<div class="app-confirm-dialog__actions">
|
||||
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
|
||||
</div>
|
||||
</app-dialog>
|
||||
}`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
|
||||
@@ -4,12 +4,22 @@ import { AdminCategoriesFacade } from '../facade/admin-categories.facade';
|
||||
import { AdminCategoryFormComponent } from '../components/admin-category-form.component';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-category-editor-page',
|
||||
standalone: true,
|
||||
imports: [AdminCategoryFormComponent, TranslatePipe],
|
||||
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-category-form [category]="draft" [parentOptions]="parentOptions()" [breadcrumb]="facade.breadcrumbFor(draft.parentId)" [children]="facade.childrenOf(draft.id)" [slugTaken]="facade.slugTaken()" [slugCheckError]="facade.slugCheckError()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (categoryChange)="facade.updateDraft($event)" (saveDraft)="save(false)" (publish)="save(true)" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`,
|
||||
imports: [AdminCategoryFormComponent, TranslatePipe, DialogComponent, ButtonComponent],
|
||||
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-category-form [category]="draft" [parentOptions]="parentOptions()" [breadcrumb]="facade.breadcrumbFor(draft.parentId)" [children]="facade.childrenOf(draft.id)" [slugTaken]="facade.slugTaken()" [slugCheckError]="facade.slugCheckError()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (categoryChange)="facade.updateDraft($event)" (saveDraft)="save(false)" (publish)="save(true)" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}
|
||||
@if (facade.mutationError()) {
|
||||
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
|
||||
<p>{{ 'common.errorDescription' | translate }}</p>
|
||||
<div class="app-confirm-dialog__actions">
|
||||
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
|
||||
</div>
|
||||
</app-dialog>
|
||||
}`,
|
||||
styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
@@ -41,8 +51,9 @@ export class AdminCategoryEditorPageComponent {
|
||||
}
|
||||
|
||||
save(publish: boolean): void {
|
||||
this.facade.saveDraft(publish);
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']);
|
||||
this.facade.saveDraft(publish, () => {
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']);
|
||||
});
|
||||
}
|
||||
|
||||
private descendantIds(id: string): string[] {
|
||||
|
||||
@@ -92,6 +92,7 @@ export class AdminProductsFacade {
|
||||
readonly categories = signal<AdminProductCategoryOption[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly mutationError = signal<string | null>(null);
|
||||
readonly selectedIds = signal<string[]>([]);
|
||||
readonly draft = signal<AdminProduct | null>(null);
|
||||
readonly dirty = signal(false);
|
||||
@@ -305,18 +306,26 @@ export class AdminProductsFacade {
|
||||
this.dirty.set(true);
|
||||
}
|
||||
|
||||
saveDraft(): void {
|
||||
saveDraft(onSuccess?: () => void): void {
|
||||
const draft = this.draft();
|
||||
if (!draft) return;
|
||||
|
||||
this.mutationError.set(null);
|
||||
const request = this.editorMode() === 'create'
|
||||
? this.gateway.createProduct(draft)
|
||||
: this.gateway.updateProduct(draft);
|
||||
|
||||
request.pipe(take(1)).subscribe({ next: () => { this.dirty.set(false); this.loadList(); } });
|
||||
request.pipe(take(1)).subscribe({
|
||||
next: () => { this.dirty.set(false); this.loadList(); onSuccess?.(); },
|
||||
error: () => this.mutationError.set('common.errorDescription')
|
||||
});
|
||||
}
|
||||
|
||||
deleteOne(id: string): void {
|
||||
this.gateway.deleteProduct(id).pipe(take(1)).subscribe({ next: () => this.loadList() });
|
||||
this.mutationError.set(null);
|
||||
this.gateway.deleteProduct(id).pipe(take(1)).subscribe({
|
||||
next: () => this.loadList(),
|
||||
error: () => this.mutationError.set('common.errorDescription')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,22 @@ import { AdminProductsFacade } from '../facade/admin-products.facade';
|
||||
import { AdminProductFormComponent } from '../components/admin-product-form.component';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-product-editor-page',
|
||||
standalone: true,
|
||||
imports: [AdminProductFormComponent, TranslatePipe],
|
||||
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-product-form [product]="draft" [categories]="facade.categories()" [allProducts]="facade.products()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (productChange)="facade.updateDraft($event)" (save)="save()" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`,
|
||||
imports: [AdminProductFormComponent, TranslatePipe, DialogComponent, ButtonComponent],
|
||||
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-product-form [product]="draft" [categories]="facade.categories()" [allProducts]="facade.products()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (productChange)="facade.updateDraft($event)" (save)="save()" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}
|
||||
@if (facade.mutationError()) {
|
||||
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
|
||||
<p>{{ 'common.errorDescription' | translate }}</p>
|
||||
<div class="app-confirm-dialog__actions">
|
||||
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
|
||||
</div>
|
||||
</app-dialog>
|
||||
}`,
|
||||
styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
@@ -33,7 +43,8 @@ export class AdminProductEditorPageComponent {
|
||||
}
|
||||
|
||||
save(): void {
|
||||
this.facade.saveDraft();
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'products']);
|
||||
this.facade.saveDraft(() => {
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'products']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { AdminProductsListComponent } from '../components/admin-products-list.co
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-products-list-page',
|
||||
standalone: true,
|
||||
imports: [AdminProductsListComponent, ConfirmDialogComponent, TranslatePipe],
|
||||
imports: [AdminProductsListComponent, ConfirmDialogComponent, TranslatePipe, DialogComponent, ButtonComponent],
|
||||
template: `<app-admin-products-list
|
||||
[products]="facade.products()"
|
||||
[categories]="facade.categories()"
|
||||
@@ -57,7 +59,15 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
[message]="'adminProducts.confirmBulkDelete' | translate"
|
||||
[destructive]="true"
|
||||
(confirmed)="confirmBulkDelete()"
|
||||
(cancelled)="bulkDeleteConfirmOpen.set(false)" />`,
|
||||
(cancelled)="bulkDeleteConfirmOpen.set(false)" />
|
||||
@if (facade.mutationError()) {
|
||||
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
|
||||
<p>{{ 'common.errorDescription' | translate }}</p>
|
||||
<div class="app-confirm-dialog__actions">
|
||||
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
|
||||
</div>
|
||||
</app-dialog>
|
||||
}`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AdminProductsListPageComponent {
|
||||
|
||||
@@ -12,6 +12,7 @@ export class AdminUsersFacade {
|
||||
readonly invitations = signal<AdminInvitation[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal(false);
|
||||
readonly mutationError = signal<string | null>(null);
|
||||
readonly sessionsTarget = signal<AdminUser | null>(null);
|
||||
readonly sessions = signal<AdminSession[]>([]);
|
||||
readonly auditTarget = signal<AdminUser | null>(null);
|
||||
@@ -33,16 +34,28 @@ export class AdminUsersFacade {
|
||||
}
|
||||
|
||||
setRole(userId: string, roleId: string): void {
|
||||
this.gateway.setUserRole(userId, roleId).pipe(take(1)).subscribe({ next: () => this.loadAll() });
|
||||
this.mutationError.set(null);
|
||||
this.gateway.setUserRole(userId, roleId).pipe(take(1)).subscribe({
|
||||
next: () => this.loadAll(),
|
||||
error: () => this.mutationError.set('common.errorDescription')
|
||||
});
|
||||
}
|
||||
|
||||
setStatus(userId: string, status: AdminUserStatus): void {
|
||||
this.gateway.setUserStatus(userId, status).pipe(take(1)).subscribe({ next: () => this.loadAll() });
|
||||
this.mutationError.set(null);
|
||||
this.gateway.setUserStatus(userId, status).pipe(take(1)).subscribe({
|
||||
next: () => this.loadAll(),
|
||||
error: () => this.mutationError.set('common.errorDescription')
|
||||
});
|
||||
}
|
||||
|
||||
invite(email: string, roleId: string, scope: AdminUserScope): void {
|
||||
if (!email.trim()) return;
|
||||
this.gateway.inviteUser(email.trim(), roleId, scope).pipe(take(1)).subscribe({ next: () => this.loadAll() });
|
||||
this.mutationError.set(null);
|
||||
this.gateway.inviteUser(email.trim(), roleId, scope).pipe(take(1)).subscribe({
|
||||
next: () => this.loadAll(),
|
||||
error: () => this.mutationError.set('common.errorDescription')
|
||||
});
|
||||
}
|
||||
|
||||
revokeInvitation(id: string): void {
|
||||
|
||||
@@ -128,4 +128,13 @@
|
||||
[destructive]="true"
|
||||
(confirmed)="confirmSuspend()"
|
||||
(cancelled)="pendingSuspendUserId.set(null)" />
|
||||
|
||||
@if (facade.mutationError()) {
|
||||
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
|
||||
<p>{{ 'common.errorDescription' | translate }}</p>
|
||||
<div class="app-confirm-dialog__actions">
|
||||
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
|
||||
</div>
|
||||
</app-dialog>
|
||||
}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user