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:
sdarbinyan
2026-08-13 08:53:12 +04:00
parent d3d6632375
commit 000bb78112
8 changed files with 98 additions and 19 deletions

View File

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

View File

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

View File

@@ -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[] {

View File

@@ -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')
});
}
}

View File

@@ -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']);
});
}
}

View File

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

View File

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

View File

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