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 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 {
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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[] {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user