fix: load errors swallowed to empty array across 5 admin facades

Orders/Products/Categories/Customers/Transactions loadList() caught
errors by silently clearing the list to [] with no error state - an
API failure looked identical to a genuine 'no results' empty state.

Added an error signal to each facade (set on failure, cleared on
retry) and an error branch in each list page/component, distinct from
both loading and the real empty state.

Order and Customer detail (loadDetail) had it worse: no error handler
at all, so a failure just left the page on 'Loading...' forever with
nothing to retry or navigate away with. Added selectedLoading/
selectedError to both facades and an error screen with a back button
to both detail pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-08-13 08:44:30 +04:00
parent c461d9bd5f
commit 1e84d67e24
16 changed files with 64 additions and 1 deletions

View File

@@ -64,6 +64,8 @@
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (error) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (treeRows.length === 0) {
<app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate">
<span slot="actions">

View File

@@ -51,6 +51,7 @@ export class AdminCategoriesListComponent {
@Input() flatCategories: AdminCategory[] = [];
@Input() filters!: { search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean };
@Input() loading = false;
@Input() error: string | null = null;
@Input() viewMode: AdminCategoriesViewMode = 'tree';
@Input() density: AdminCategoriesDensity = 'comfortable';
@Input() visibleColumns: AdminCategoryColumn[] = [...ALL_CATEGORY_COLUMNS];

View File

@@ -131,6 +131,7 @@ export class AdminCategoriesFacade {
readonly filters = signal<AdminCategoryListFilters>({ search: '', visibility: 'all', includeDeleted: false });
readonly categories = signal<AdminCategory[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly draft = signal<AdminCategory | null>(null);
readonly editorMode = signal<AdminCategoryEditorMode>('create');
readonly dirty = signal(false);
@@ -174,6 +175,7 @@ export class AdminCategoriesFacade {
*/
loadList(): void {
this.loading.set(true);
this.error.set(null);
this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({
next: categories => {
this.categories.set(categories);
@@ -182,6 +184,7 @@ export class AdminCategoriesFacade {
error: () => {
this.categories.set([]);
this.loading.set(false);
this.error.set('common.errorDescription');
}
});
}

View File

@@ -19,6 +19,7 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
[allCategories]="facade.categories()"
[filters]="facade.filters()"
[loading]="facade.loading()"
[error]="facade.error()"
[viewMode]="facade.viewMode()"
[density]="facade.density()"
[visibleColumns]="facade.visibleColumns()"

View File

@@ -10,8 +10,11 @@ export class AdminCustomersFacade {
readonly customers = signal<AdminCustomer[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly search = signal('');
readonly selected = signal<AdminCustomer | null>(null);
readonly selectedLoading = signal(false);
readonly selectedError = signal<string | null>(null);
private buildCustomers(orders: AdminOrder[]): AdminCustomer[] {
const byEmail = new Map<string, AdminOrder[]>();
@@ -41,6 +44,7 @@ export class AdminCustomersFacade {
loadList(): void {
this.loading.set(true);
this.error.set(null);
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
next: result => {
this.customers.set(this.buildCustomers(result.items));
@@ -49,16 +53,24 @@ export class AdminCustomersFacade {
error: () => {
this.customers.set([]);
this.loading.set(false);
this.error.set('common.errorDescription');
}
});
}
loadDetail(email: string): void {
this.selectedLoading.set(true);
this.selectedError.set(null);
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
next: result => {
const decoded = decodeURIComponent(email);
const customers = this.buildCustomers(result.items);
this.selected.set(customers.find(customer => customer.email === decoded) ?? null);
this.selectedLoading.set(false);
},
error: () => {
this.selectedLoading.set(false);
this.selectedError.set('common.errorDescription');
}
});
}

View File

@@ -48,6 +48,12 @@
<app-order-timeline [entries]="activity()" [showOrderNumber]="true" />
</section>
</main>
} @else if (facade.selectedError()) {
<div class="customer-detail-error" role="alert">
<p>{{ 'common.errorTitle' | translate }}</p>
<p>{{ 'common.errorDescription' | translate }}</p>
<app-button variant="secondary" size="sm" (click)="back()">{{ 'adminCustomers.back' | translate }}</app-button>
</div>
} @else {
<p>{{ 'common.loading' | translate }}</p>
}

View File

@@ -8,6 +8,8 @@
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.filteredCustomers().length === 0) {
<app-empty-state [title]="'adminCustomers.emptyTitle' | translate" [description]="'adminCustomers.emptyDescription' | translate" />
} @else {

View File

@@ -37,7 +37,10 @@ export class AdminOrdersFacade {
readonly orders = signal<AdminOrder[]>([]);
readonly total = signal(0);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly selected = signal<AdminOrder | null>(null);
readonly selectedLoading = signal(false);
readonly selectedError = signal<string | null>(null);
readonly viewMode = signal<AdminOrdersViewMode>((this.localStorage.getItem(VIEW_MODE_KEY) as AdminOrdersViewMode) || 'table');
readonly density = signal<AdminOrdersDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminOrdersDensity) || 'comfortable');
@@ -76,6 +79,7 @@ export class AdminOrdersFacade {
loadList(): void {
this.loading.set(true);
this.error.set(null);
this.gateway.loadOrders(this.filters()).pipe(take(1)).subscribe({
next: result => {
this.orders.set(result.items);
@@ -86,6 +90,7 @@ export class AdminOrdersFacade {
this.orders.set([]);
this.total.set(0);
this.loading.set(false);
this.error.set('common.errorDescription');
}
});
}
@@ -96,7 +101,18 @@ export class AdminOrdersFacade {
}
loadDetail(id: string): void {
this.gateway.loadOrder(id).pipe(take(1)).subscribe({ next: order => this.selected.set(order) });
this.selectedLoading.set(true);
this.selectedError.set(null);
this.gateway.loadOrder(id).pipe(take(1)).subscribe({
next: order => {
this.selected.set(order);
this.selectedLoading.set(false);
},
error: () => {
this.selectedLoading.set(false);
this.selectedError.set('common.errorDescription');
}
});
}
setStatus(id: string, status: AdminOrderStatus): void {

View File

@@ -103,6 +103,12 @@
(confirmed)="confirmRefund()"
(cancelled)="pendingRefundId.set(null)" />
</main>
} @else if (facade.selectedError()) {
<div class="order-detail-error" role="alert">
<p>{{ 'common.errorTitle' | translate }}</p>
<p>{{ 'common.errorDescription' | translate }}</p>
<app-button variant="secondary" size="sm" (click)="back()">{{ 'adminOrders.back' | translate }}</app-button>
</div>
} @else {
<p>{{ 'common.loading' | translate }}</p>
}

View File

@@ -61,6 +61,8 @@
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.orders().length === 0) {
<app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" />
} @else {

View File

@@ -78,6 +78,8 @@
}
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (error) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (products.length === 0) {
<app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate">
<span slot="actions">

View File

@@ -44,6 +44,7 @@ export class AdminProductsListComponent {
@Input() total = 0;
@Input() selectedIds: string[] = [];
@Input() loading = false;
@Input() error: string | null = null;
@Input() infiniteScroll = false;
@Input() viewMode: AdminProductsViewMode = 'table';
@Input() density: AdminProductsDensity = 'comfortable';

View File

@@ -91,6 +91,7 @@ export class AdminProductsFacade {
readonly infiniteScroll = signal(false);
readonly categories = signal<AdminProductCategoryOption[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly selectedIds = signal<string[]>([]);
readonly draft = signal<AdminProduct | null>(null);
readonly dirty = signal(false);
@@ -100,6 +101,7 @@ export class AdminProductsFacade {
loadList(): void {
this.loading.set(true);
this.error.set(null);
this.gateway.loadProducts(this.filters()).pipe(take(1)).subscribe({
next: result => {
this.products.set(result.items);
@@ -110,6 +112,7 @@ export class AdminProductsFacade {
this.products.set([]);
this.total.set(0);
this.loading.set(false);
this.error.set('common.errorDescription');
}
});
}

View File

@@ -17,6 +17,7 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
[total]="facade.total()"
[selectedIds]="facade.selectedIds()"
[loading]="facade.loading()"
[error]="facade.error()"
[infiniteScroll]="facade.infiniteScroll()"
[viewMode]="facade.viewMode()"
[density]="facade.density()"

View File

@@ -11,9 +11,11 @@ export class AdminTransactionsFacade {
readonly transactions = signal<AdminTransaction[]>([]);
readonly total = signal(0);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
loadList(): void {
this.loading.set(true);
this.error.set(null);
this.gateway.loadTransactions(this.filters()).pipe(take(1)).subscribe({
next: result => {
this.transactions.set(result.items);
@@ -24,6 +26,7 @@ export class AdminTransactionsFacade {
this.transactions.set([]);
this.total.set(0);
this.loading.set(false);
this.error.set('common.errorDescription');
}
});
}

View File

@@ -22,6 +22,8 @@
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.transactions().length === 0) {
<app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" />
} @else {