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

View File

@@ -51,6 +51,7 @@ export class AdminCategoriesListComponent {
@Input() flatCategories: AdminCategory[] = []; @Input() flatCategories: AdminCategory[] = [];
@Input() filters!: { search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean }; @Input() filters!: { search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean };
@Input() loading = false; @Input() loading = false;
@Input() error: string | null = null;
@Input() viewMode: AdminCategoriesViewMode = 'tree'; @Input() viewMode: AdminCategoriesViewMode = 'tree';
@Input() density: AdminCategoriesDensity = 'comfortable'; @Input() density: AdminCategoriesDensity = 'comfortable';
@Input() visibleColumns: AdminCategoryColumn[] = [...ALL_CATEGORY_COLUMNS]; @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 filters = signal<AdminCategoryListFilters>({ search: '', visibility: 'all', includeDeleted: false });
readonly categories = signal<AdminCategory[]>([]); readonly categories = signal<AdminCategory[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly draft = signal<AdminCategory | null>(null); readonly draft = signal<AdminCategory | null>(null);
readonly editorMode = signal<AdminCategoryEditorMode>('create'); readonly editorMode = signal<AdminCategoryEditorMode>('create');
readonly dirty = signal(false); readonly dirty = signal(false);
@@ -174,6 +175,7 @@ export class AdminCategoriesFacade {
*/ */
loadList(): void { loadList(): void {
this.loading.set(true); this.loading.set(true);
this.error.set(null);
this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({ this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({
next: categories => { next: categories => {
this.categories.set(categories); this.categories.set(categories);
@@ -182,6 +184,7 @@ export class AdminCategoriesFacade {
error: () => { error: () => {
this.categories.set([]); this.categories.set([]);
this.loading.set(false); this.loading.set(false);
this.error.set('common.errorDescription');
} }
}); });
} }

View File

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

View File

@@ -10,8 +10,11 @@ export class AdminCustomersFacade {
readonly customers = signal<AdminCustomer[]>([]); readonly customers = signal<AdminCustomer[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly search = signal(''); readonly search = signal('');
readonly selected = signal<AdminCustomer | null>(null); readonly selected = signal<AdminCustomer | null>(null);
readonly selectedLoading = signal(false);
readonly selectedError = signal<string | null>(null);
private buildCustomers(orders: AdminOrder[]): AdminCustomer[] { private buildCustomers(orders: AdminOrder[]): AdminCustomer[] {
const byEmail = new Map<string, AdminOrder[]>(); const byEmail = new Map<string, AdminOrder[]>();
@@ -41,6 +44,7 @@ export class AdminCustomersFacade {
loadList(): void { loadList(): void {
this.loading.set(true); this.loading.set(true);
this.error.set(null);
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({ this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
next: result => { next: result => {
this.customers.set(this.buildCustomers(result.items)); this.customers.set(this.buildCustomers(result.items));
@@ -49,16 +53,24 @@ export class AdminCustomersFacade {
error: () => { error: () => {
this.customers.set([]); this.customers.set([]);
this.loading.set(false); this.loading.set(false);
this.error.set('common.errorDescription');
} }
}); });
} }
loadDetail(email: string): void { 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({ this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
next: result => { next: result => {
const decoded = decodeURIComponent(email); const decoded = decodeURIComponent(email);
const customers = this.buildCustomers(result.items); const customers = this.buildCustomers(result.items);
this.selected.set(customers.find(customer => customer.email === decoded) ?? null); 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" /> <app-order-timeline [entries]="activity()" [showOrderNumber]="true" />
</section> </section>
</main> </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 { } @else {
<p>{{ 'common.loading' | translate }}</p> <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" /> } @for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span> <span class="sr-only">{{ 'common.loading' | translate }}</span>
</div> </div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.filteredCustomers().length === 0) { } @else if (facade.filteredCustomers().length === 0) {
<app-empty-state [title]="'adminCustomers.emptyTitle' | translate" [description]="'adminCustomers.emptyDescription' | translate" /> <app-empty-state [title]="'adminCustomers.emptyTitle' | translate" [description]="'adminCustomers.emptyDescription' | translate" />
} @else { } @else {

View File

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

View File

@@ -103,6 +103,12 @@
(confirmed)="confirmRefund()" (confirmed)="confirmRefund()"
(cancelled)="pendingRefundId.set(null)" /> (cancelled)="pendingRefundId.set(null)" />
</main> </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 { } @else {
<p>{{ 'common.loading' | translate }}</p> <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" /> } @for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span> <span class="sr-only">{{ 'common.loading' | translate }}</span>
</div> </div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.orders().length === 0) { } @else if (facade.orders().length === 0) {
<app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" /> <app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" />
} @else { } @else {

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,9 +11,11 @@ export class AdminTransactionsFacade {
readonly transactions = signal<AdminTransaction[]>([]); readonly transactions = signal<AdminTransaction[]>([]);
readonly total = signal(0); readonly total = signal(0);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null);
loadList(): void { loadList(): void {
this.loading.set(true); this.loading.set(true);
this.error.set(null);
this.gateway.loadTransactions(this.filters()).pipe(take(1)).subscribe({ this.gateway.loadTransactions(this.filters()).pipe(take(1)).subscribe({
next: result => { next: result => {
this.transactions.set(result.items); this.transactions.set(result.items);
@@ -24,6 +26,7 @@ export class AdminTransactionsFacade {
this.transactions.set([]); this.transactions.set([]);
this.total.set(0); this.total.set(0);
this.loading.set(false); 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" /> } @for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span> <span class="sr-only">{{ 'common.loading' | translate }}</span>
</div> </div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.transactions().length === 0) { } @else if (facade.transactions().length === 0) {
<app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" /> <app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" />
} @else { } @else {