feat(product-ticket-selector): enhance variant selection handling and add toast notifications for errors

This commit is contained in:
2026-08-14 15:44:30 -03:00
parent d92b5a7623
commit 3f8b5bf206
2 changed files with 246 additions and 20 deletions

View File

@@ -1,13 +1,15 @@
import '@angular/compiler';
import { HttpErrorResponse } from '@angular/common/http';
import { signal } from '@angular/core';
import { TestBed, getTestBed } from '@angular/core/testing';
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing';
import { of } from 'rxjs';
import { of, throwError } from 'rxjs';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { CartService } from '../../../core/services/cart/cart.service';
import { CatalogService } from '../../../core/services/catalog/catalog.service';
import { ModalService } from '../../../core/services/modal.service';
import { ToastService } from '../../../core/services/toast.service';
import { ProductTicketSelectorComponent } from './product-ticket-selector.component';
describe('ProductTicketSelectorComponent', () => {
@@ -285,4 +287,147 @@ describe('ProductTicketSelectorComponent', () => {
fixture.nativeElement.querySelector('.ticket-selector__validation').textContent,
).toContain('Validado');
});
it('keeps the reserved selection and shows a toast when a variant change cannot be completed', async () => {
const cartState = signal({
id: 10,
tenant_codigo: 'demo',
status: 'active',
subtotal: '10000.00',
items: [
{
id: 25,
cantidad: 1,
precio_unitario: '10000.00',
catalog_item_id: 7,
variant_id: 401,
product: {
nombre: 'Entrada',
imagen: null,
variants: [
{
id: 401,
precio: '10000.00',
stock_tecnico: 0,
values: { seat: '1' },
},
],
},
},
],
});
const selector = {
key: 'seat',
label: 'Asiento',
options: ['1', '2'],
enabled: true,
};
const summary = {
valid: true,
available_variant_count: 2,
matching_variant_count: 1,
price_range: { minimum: '10000.00', maximum: '10000.00' },
selectors: [selector],
};
const catalogService = {
withoutLoading: vi.fn(),
getVariantOptions: vi
.fn()
.mockReturnValueOnce(
of({
...summary,
selected_values: { seat: '1' },
resolved_variant: {
id: 401,
precio: '10000.00',
stock_tecnico: 0,
values: { seat: '1' },
},
}),
)
.mockReturnValueOnce(
of({
...summary,
selected_values: { seat: '2' },
resolved_variant: {
id: 402,
precio: '10000.00',
stock_tecnico: 1,
values: { seat: '2' },
},
}),
),
};
const errorMessage = 'La entrada seleccionada ya no está disponible.';
const cartService = {
cart: cartState.asReadonly(),
withoutLoading: vi.fn(),
updateItemVariant: vi.fn().mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 422,
error: { message: errorMessage },
}),
),
),
};
const toastService = { danger: vi.fn() };
catalogService.withoutLoading.mockReturnValue(catalogService);
cartService.withoutLoading.mockReturnValue(cartService);
await TestBed.configureTestingModule({
imports: [ProductTicketSelectorComponent],
providers: [
{ provide: CatalogService, useValue: catalogService },
{ provide: CartService, useValue: cartService },
{ provide: ToastService, useValue: toastService },
],
}).compileComponents();
const fixture = TestBed.createComponent(ProductTicketSelectorComponent);
fixture.componentRef.setInput('productId', 7);
fixture.componentRef.setInput('title', 'Entrada');
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance['onSelectionChange'](1, 'seat', '2');
fixture.detectChanges();
expect(cartService.updateItemVariant).toHaveBeenCalledWith(25, 1, 402);
expect(toastService.danger).toHaveBeenCalledWith(errorMessage);
expect(fixture.componentInstance['rows']()[0]).toMatchObject({
variantId: 401,
reservedVariantId: 401,
selectedValues: { seat: '1' },
status: 'reserved',
error: null,
});
const select = fixture.nativeElement.querySelector('select') as HTMLSelectElement;
expect(select.value).toBe(JSON.stringify('1'));
const unavailableMessage = 'La combinación seleccionada ya no está disponible.';
catalogService.getVariantOptions.mockReturnValueOnce(
of({
...summary,
valid: false,
matching_variant_count: 0,
selected_values: {},
resolved_variant: null,
}),
);
fixture.componentInstance['onSelectionChange'](1, 'seat', '2');
fixture.detectChanges();
expect(cartService.updateItemVariant).toHaveBeenCalledTimes(1);
expect(toastService.danger).toHaveBeenLastCalledWith(unavailableMessage);
expect(fixture.componentInstance['rows']()[0]).toMatchObject({
variantId: 401,
reservedVariantId: 401,
selectedValues: { seat: '1' },
status: 'reserved',
error: null,
});
expect(select.value).toBe(JSON.stringify('1'));
});
});

View File

@@ -18,6 +18,7 @@ import { Subscription } from 'rxjs';
import { CartService } from '../../../core/services/cart/cart.service';
import { CartItem, CartItemVariantValue } from '../../../core/services/cart/cart.interface';
import { ModalService } from '../../../core/services/modal.service';
import { ToastService } from '../../../core/services/toast.service';
import {
CatalogVariantOptionsResponse,
CatalogVariantSelector,
@@ -44,6 +45,8 @@ interface TicketSelectionRow {
cartItemId: number | null;
selectedValues: Record<string, string | string[]>;
selectors: CatalogVariantSelector[];
reservedSelectedValues: Record<string, string | string[]>;
reservedSelectors: CatalogVariantSelector[];
status: TicketSelectionStatus;
error: string | null;
}
@@ -59,6 +62,7 @@ export class ProductTicketSelectorComponent {
private readonly catalogService = inject(CatalogService);
private readonly cartService = inject(CartService);
private readonly modalService = inject(ModalService);
private readonly toastService = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
private readonly optionRequests = new Map<number, Subscription>();
private readonly reservationRequests = new Map<number, Subscription>();
@@ -222,7 +226,12 @@ export class ProductTicketSelectorComponent {
if (value === null) delete selectedValues[selectorKey];
else selectedValues[selectorKey] = this.normalizeValue(value);
this.loadOptions(rowId, selectedValues);
const preservesReservedSelection =
row.reservedVariantId !== null &&
row.selectors.length > 0 &&
row.selectors.every(({ key }) => key in selectedValues);
this.loadOptions(rowId, selectedValues, preservesReservedSelection);
}
protected optionLabel(value: CatalogVariantValue): string {
@@ -259,12 +268,20 @@ export class ProductTicketSelectorComponent {
return this.disabled() || this.isBusy(row);
}
private loadOptions(rowId: number, selectedValues: Record<string, string | string[]>): void {
private loadOptions(
rowId: number,
selectedValues: Record<string, string | string[]>,
preserveReservedSelection = false,
): void {
const row = this.findRow(rowId);
if (!row) return;
this.optionRequests.get(rowId)?.unsubscribe();
this.patchRow(rowId, { selectedValues, status: 'checking', error: null });
this.patchRow(rowId, {
selectedValues: preserveReservedSelection ? row.reservedSelectedValues : selectedValues,
status: 'checking',
error: null,
});
const request = this.catalogService
.withoutLoading()
@@ -277,6 +294,15 @@ export class ProductTicketSelectorComponent {
this.applySummary(response, row);
if (!response.valid) {
if (row.reservedVariantId !== null) {
this.restoreReservedRowAfterError(
rowId,
row,
'La combinación seleccionada ya no está disponible.',
);
return;
}
this.patchRow(rowId, {
variantId: null,
selectedValues: {},
@@ -292,35 +318,61 @@ export class ProductTicketSelectorComponent {
this.patchRow(rowId, {
variantId: response.resolved_variant?.id ?? null,
selectedValues: response.selected_values,
selectors: response.selectors,
selectedValues: preserveReservedSelection
? row.reservedSelectedValues
: response.selected_values,
selectors: preserveReservedSelection ? row.reservedSelectors : response.selectors,
status: 'selecting',
error: null,
});
if (response.resolved_variant !== null) {
if (row.cartItemId !== null && row.reservedVariantId === response.resolved_variant.id) {
this.patchRow(rowId, { status: 'reserved' });
this.patchRow(rowId, {
reservedSelectedValues: response.selected_values,
reservedSelectors: response.selectors,
status: 'reserved',
});
} else {
this.reserveRow(rowId, response.resolved_variant.id);
this.reserveRow(
rowId,
response.resolved_variant.id,
response.selected_values,
response.selectors,
);
}
}
},
error: (error: HttpErrorResponse) => {
this.patchRow(rowId, {
status: 'error',
error: this.errorMessage(error, 'No se pudo consultar la disponibilidad.'),
});
const message = this.errorMessage(error, 'No se pudo consultar la disponibilidad.');
if (row.reservedVariantId !== null) {
this.restoreReservedRowAfterError(rowId, row, message);
return;
}
this.patchRow(rowId, { status: 'error', error: message });
this.toastService.danger(message);
},
});
this.optionRequests.set(rowId, request);
}
private reserveRow(rowId: number, variantId: number): void {
private reserveRow(
rowId: number,
variantId: number,
selectedValues: Record<string, string | string[]>,
selectors: CatalogVariantSelector[],
): void {
const row = this.findRow(rowId);
if (!row || (row.status === 'reserved' && row.reservedVariantId === variantId)) return;
this.patchRow(rowId, { status: 'reserving', error: null });
this.patchRow(rowId, {
selectedValues: row.reservedVariantId === null ? selectedValues : row.reservedSelectedValues,
selectors: row.reservedVariantId === null ? selectors : row.reservedSelectors,
status: 'reserving',
error: null,
});
const cartService = this.cartService.withoutLoading();
const operation =
row.cartItemId === null
@@ -348,6 +400,10 @@ export class ProductTicketSelectorComponent {
this.patchRow(rowId, {
variantId,
reservedVariantId: variantId,
selectedValues,
selectors,
reservedSelectedValues: selectedValues,
reservedSelectors: selectors,
cartItemId: cartItem.id,
status: 'reserved',
error: null,
@@ -357,16 +413,35 @@ export class ProductTicketSelectorComponent {
}
},
error: (error: HttpErrorResponse) => {
this.patchRow(rowId, {
variantId: row.reservedVariantId,
status: row.reservedVariantId === null ? 'error' : 'reserved',
error: this.errorMessage(error, 'La entrada ya no está disponible.'),
});
const message = this.errorMessage(error, 'La entrada ya no está disponible.');
if (row.reservedVariantId !== null) {
this.restoreReservedRowAfterError(rowId, row, message);
return;
}
this.patchRow(rowId, { variantId: null, status: 'error', error: message });
this.toastService.danger(message);
},
});
this.reservationRequests.set(rowId, request);
}
private restoreReservedRowAfterError(
rowId: number,
row: TicketSelectionRow,
message: string,
): void {
this.patchRow(rowId, {
variantId: row.reservedVariantId,
selectedValues: row.reservedSelectedValues,
selectors: row.reservedSelectors,
status: 'reserved',
error: null,
});
this.toastService.danger(message);
}
private applySummary(response: CatalogVariantOptionsResponse, row: TicketSelectionRow): void {
this.availableVariantCount.set(
Math.max(0, response.available_variant_count - (row.cartItemId === null ? 0 : 1)),
@@ -385,6 +460,8 @@ export class ProductTicketSelectorComponent {
cartItemId: null,
selectedValues: {},
selectors: [],
reservedSelectedValues: {},
reservedSelectors: [],
status: 'checking',
error: null,
};
@@ -396,13 +473,17 @@ export class ProductTicketSelectorComponent {
const variant = item.product?.variants?.find(({ id }) => id === item.variant_id);
if (!variant) return null;
const selectedValues = this.normalizeCartValues(variant.values);
return {
id,
variantId: item.variant_id,
reservedVariantId: item.variant_id,
cartItemId: item.id,
selectedValues: this.normalizeCartValues(variant.values),
selectedValues,
selectors: [],
reservedSelectedValues: selectedValues,
reservedSelectors: [],
status: 'checking',
error: null,
};