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 dirty = signal(false);
readonly slugTaken = signal(false); readonly slugTaken = signal(false);
readonly slugCheckError = signal(false); readonly slugCheckError = signal(false);
readonly mutationError = signal<string | null>(null);
private savedSnapshot: string | null = null; private savedSnapshot: string | null = null;
private draftStorageKey: 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(); const draft = this.draft();
if (!draft || this.slugTaken() || this.slugCheckError()) return; if (!draft || this.slugTaken() || this.slugCheckError()) return;
this.mutationError.set(null);
const toSave: AdminCategory = { ...draft, status: publish ? 'published' : 'draft', updatedAt: new Date().toISOString() }; 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); 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.savedSnapshot = JSON.stringify(saved);
this.dirty.set(false); this.dirty.set(false);
this.loadList(); this.loadList();
} onSuccess?.();
},
error: () => this.mutationError.set('common.errorDescription')
}); });
} }
@@ -357,7 +361,11 @@ export class AdminCategoriesFacade {
} }
deleteOne(id: string): void { 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 { 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> <app-button variant="primary" (click)="deleteBlockedMessage.set(null)">{{ 'common.confirm' | translate }}</app-button>
</div> </div>
</app-dialog> </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 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 { AdminCategoryFormComponent } from '../components/admin-category-form.component';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../services/language.service'; import { LanguageService } from '../../../../services/language.service';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
@Component({ @Component({
selector: 'app-admin-category-editor-page', selector: 'app-admin-category-editor-page',
standalone: true, standalone: true,
imports: [AdminCategoryFormComponent, TranslatePipe], 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>}`, 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; }`], 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 changeDetection: ChangeDetectionStrategy.OnPush
}) })
@@ -41,8 +51,9 @@ export class AdminCategoryEditorPageComponent {
} }
save(publish: boolean): void { save(publish: boolean): void {
this.facade.saveDraft(publish); this.facade.saveDraft(publish, () => {
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']); void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']);
});
} }
private descendantIds(id: string): string[] { private descendantIds(id: string): string[] {

View File

@@ -92,6 +92,7 @@ export class AdminProductsFacade {
readonly categories = signal<AdminProductCategoryOption[]>([]); readonly categories = signal<AdminProductCategoryOption[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
readonly mutationError = signal<string | null>(null);
readonly selectedIds = signal<string[]>([]); readonly selectedIds = signal<string[]>([]);
readonly draft = signal<AdminProduct | null>(null); readonly draft = signal<AdminProduct | null>(null);
readonly dirty = signal(false); readonly dirty = signal(false);
@@ -305,18 +306,26 @@ export class AdminProductsFacade {
this.dirty.set(true); this.dirty.set(true);
} }
saveDraft(): void { saveDraft(onSuccess?: () => void): void {
const draft = this.draft(); const draft = this.draft();
if (!draft) return; if (!draft) return;
this.mutationError.set(null);
const request = this.editorMode() === 'create' const request = this.editorMode() === 'create'
? this.gateway.createProduct(draft) ? this.gateway.createProduct(draft)
: this.gateway.updateProduct(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 { 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 { AdminProductFormComponent } from '../components/admin-product-form.component';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../services/language.service'; import { LanguageService } from '../../../../services/language.service';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
@Component({ @Component({
selector: 'app-admin-product-editor-page', selector: 'app-admin-product-editor-page',
standalone: true, standalone: true,
imports: [AdminProductFormComponent, TranslatePipe], 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>}`, 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; }`], 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 changeDetection: ChangeDetectionStrategy.OnPush
}) })
@@ -33,7 +43,8 @@ export class AdminProductEditorPageComponent {
} }
save(): void { save(): void {
this.facade.saveDraft(); this.facade.saveDraft(() => {
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'products']); 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 { LanguageService } from '../../../../services/language.service';
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component'; import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
@Component({ @Component({
selector: 'app-admin-products-list-page', selector: 'app-admin-products-list-page',
standalone: true, standalone: true,
imports: [AdminProductsListComponent, ConfirmDialogComponent, TranslatePipe], imports: [AdminProductsListComponent, ConfirmDialogComponent, TranslatePipe, DialogComponent, ButtonComponent],
template: `<app-admin-products-list template: `<app-admin-products-list
[products]="facade.products()" [products]="facade.products()"
[categories]="facade.categories()" [categories]="facade.categories()"
@@ -57,7 +59,15 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
[message]="'adminProducts.confirmBulkDelete' | translate" [message]="'adminProducts.confirmBulkDelete' | translate"
[destructive]="true" [destructive]="true"
(confirmed)="confirmBulkDelete()" (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 changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class AdminProductsListPageComponent { export class AdminProductsListPageComponent {

View File

@@ -12,6 +12,7 @@ export class AdminUsersFacade {
readonly invitations = signal<AdminInvitation[]>([]); readonly invitations = signal<AdminInvitation[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal(false); readonly error = signal(false);
readonly mutationError = signal<string | null>(null);
readonly sessionsTarget = signal<AdminUser | null>(null); readonly sessionsTarget = signal<AdminUser | null>(null);
readonly sessions = signal<AdminSession[]>([]); readonly sessions = signal<AdminSession[]>([]);
readonly auditTarget = signal<AdminUser | null>(null); readonly auditTarget = signal<AdminUser | null>(null);
@@ -33,16 +34,28 @@ export class AdminUsersFacade {
} }
setRole(userId: string, roleId: string): void { 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 { 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 { invite(email: string, roleId: string, scope: AdminUserScope): void {
if (!email.trim()) return; 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 { revokeInvitation(id: string): void {

View File

@@ -128,4 +128,13 @@
[destructive]="true" [destructive]="true"
(confirmed)="confirmSuspend()" (confirmed)="confirmSuspend()"
(cancelled)="pendingSuspendUserId.set(null)" /> (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> </section>