feat(product-ticket-selector): enhance variant selection handling and add toast notifications for errors
This commit is contained in:
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user