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 '@angular/compiler';
|
||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
import { signal } from '@angular/core';
|
import { signal } from '@angular/core';
|
||||||
import { TestBed, getTestBed } from '@angular/core/testing';
|
import { TestBed, getTestBed } from '@angular/core/testing';
|
||||||
import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/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 { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { CartService } from '../../../core/services/cart/cart.service';
|
import { CartService } from '../../../core/services/cart/cart.service';
|
||||||
import { CatalogService } from '../../../core/services/catalog/catalog.service';
|
import { CatalogService } from '../../../core/services/catalog/catalog.service';
|
||||||
import { ModalService } from '../../../core/services/modal.service';
|
import { ModalService } from '../../../core/services/modal.service';
|
||||||
|
import { ToastService } from '../../../core/services/toast.service';
|
||||||
import { ProductTicketSelectorComponent } from './product-ticket-selector.component';
|
import { ProductTicketSelectorComponent } from './product-ticket-selector.component';
|
||||||
|
|
||||||
describe('ProductTicketSelectorComponent', () => {
|
describe('ProductTicketSelectorComponent', () => {
|
||||||
@@ -285,4 +287,147 @@ describe('ProductTicketSelectorComponent', () => {
|
|||||||
fixture.nativeElement.querySelector('.ticket-selector__validation').textContent,
|
fixture.nativeElement.querySelector('.ticket-selector__validation').textContent,
|
||||||
).toContain('Validado');
|
).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 { CartService } from '../../../core/services/cart/cart.service';
|
||||||
import { CartItem, CartItemVariantValue } from '../../../core/services/cart/cart.interface';
|
import { CartItem, CartItemVariantValue } from '../../../core/services/cart/cart.interface';
|
||||||
import { ModalService } from '../../../core/services/modal.service';
|
import { ModalService } from '../../../core/services/modal.service';
|
||||||
|
import { ToastService } from '../../../core/services/toast.service';
|
||||||
import {
|
import {
|
||||||
CatalogVariantOptionsResponse,
|
CatalogVariantOptionsResponse,
|
||||||
CatalogVariantSelector,
|
CatalogVariantSelector,
|
||||||
@@ -44,6 +45,8 @@ interface TicketSelectionRow {
|
|||||||
cartItemId: number | null;
|
cartItemId: number | null;
|
||||||
selectedValues: Record<string, string | string[]>;
|
selectedValues: Record<string, string | string[]>;
|
||||||
selectors: CatalogVariantSelector[];
|
selectors: CatalogVariantSelector[];
|
||||||
|
reservedSelectedValues: Record<string, string | string[]>;
|
||||||
|
reservedSelectors: CatalogVariantSelector[];
|
||||||
status: TicketSelectionStatus;
|
status: TicketSelectionStatus;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,7 @@ export class ProductTicketSelectorComponent {
|
|||||||
private readonly catalogService = inject(CatalogService);
|
private readonly catalogService = inject(CatalogService);
|
||||||
private readonly cartService = inject(CartService);
|
private readonly cartService = inject(CartService);
|
||||||
private readonly modalService = inject(ModalService);
|
private readonly modalService = inject(ModalService);
|
||||||
|
private readonly toastService = inject(ToastService);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly optionRequests = new Map<number, Subscription>();
|
private readonly optionRequests = new Map<number, Subscription>();
|
||||||
private readonly reservationRequests = new Map<number, Subscription>();
|
private readonly reservationRequests = new Map<number, Subscription>();
|
||||||
@@ -222,7 +226,12 @@ export class ProductTicketSelectorComponent {
|
|||||||
if (value === null) delete selectedValues[selectorKey];
|
if (value === null) delete selectedValues[selectorKey];
|
||||||
else selectedValues[selectorKey] = this.normalizeValue(value);
|
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 {
|
protected optionLabel(value: CatalogVariantValue): string {
|
||||||
@@ -259,12 +268,20 @@ export class ProductTicketSelectorComponent {
|
|||||||
return this.disabled() || this.isBusy(row);
|
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);
|
const row = this.findRow(rowId);
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
|
|
||||||
this.optionRequests.get(rowId)?.unsubscribe();
|
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
|
const request = this.catalogService
|
||||||
.withoutLoading()
|
.withoutLoading()
|
||||||
@@ -277,6 +294,15 @@ export class ProductTicketSelectorComponent {
|
|||||||
this.applySummary(response, row);
|
this.applySummary(response, row);
|
||||||
|
|
||||||
if (!response.valid) {
|
if (!response.valid) {
|
||||||
|
if (row.reservedVariantId !== null) {
|
||||||
|
this.restoreReservedRowAfterError(
|
||||||
|
rowId,
|
||||||
|
row,
|
||||||
|
'La combinación seleccionada ya no está disponible.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.patchRow(rowId, {
|
this.patchRow(rowId, {
|
||||||
variantId: null,
|
variantId: null,
|
||||||
selectedValues: {},
|
selectedValues: {},
|
||||||
@@ -292,35 +318,61 @@ export class ProductTicketSelectorComponent {
|
|||||||
|
|
||||||
this.patchRow(rowId, {
|
this.patchRow(rowId, {
|
||||||
variantId: response.resolved_variant?.id ?? null,
|
variantId: response.resolved_variant?.id ?? null,
|
||||||
selectedValues: response.selected_values,
|
selectedValues: preserveReservedSelection
|
||||||
selectors: response.selectors,
|
? row.reservedSelectedValues
|
||||||
|
: response.selected_values,
|
||||||
|
selectors: preserveReservedSelection ? row.reservedSelectors : response.selectors,
|
||||||
status: 'selecting',
|
status: 'selecting',
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.resolved_variant !== null) {
|
if (response.resolved_variant !== null) {
|
||||||
if (row.cartItemId !== null && row.reservedVariantId === response.resolved_variant.id) {
|
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 {
|
} else {
|
||||||
this.reserveRow(rowId, response.resolved_variant.id);
|
this.reserveRow(
|
||||||
|
rowId,
|
||||||
|
response.resolved_variant.id,
|
||||||
|
response.selected_values,
|
||||||
|
response.selectors,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (error: HttpErrorResponse) => {
|
error: (error: HttpErrorResponse) => {
|
||||||
this.patchRow(rowId, {
|
const message = this.errorMessage(error, 'No se pudo consultar la disponibilidad.');
|
||||||
status: 'error',
|
|
||||||
error: 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);
|
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);
|
const row = this.findRow(rowId);
|
||||||
if (!row || (row.status === 'reserved' && row.reservedVariantId === variantId)) return;
|
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 cartService = this.cartService.withoutLoading();
|
||||||
const operation =
|
const operation =
|
||||||
row.cartItemId === null
|
row.cartItemId === null
|
||||||
@@ -348,6 +400,10 @@ export class ProductTicketSelectorComponent {
|
|||||||
this.patchRow(rowId, {
|
this.patchRow(rowId, {
|
||||||
variantId,
|
variantId,
|
||||||
reservedVariantId: variantId,
|
reservedVariantId: variantId,
|
||||||
|
selectedValues,
|
||||||
|
selectors,
|
||||||
|
reservedSelectedValues: selectedValues,
|
||||||
|
reservedSelectors: selectors,
|
||||||
cartItemId: cartItem.id,
|
cartItemId: cartItem.id,
|
||||||
status: 'reserved',
|
status: 'reserved',
|
||||||
error: null,
|
error: null,
|
||||||
@@ -357,16 +413,35 @@ export class ProductTicketSelectorComponent {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (error: HttpErrorResponse) => {
|
error: (error: HttpErrorResponse) => {
|
||||||
this.patchRow(rowId, {
|
const message = this.errorMessage(error, 'La entrada ya no está disponible.');
|
||||||
variantId: row.reservedVariantId,
|
|
||||||
status: row.reservedVariantId === null ? 'error' : 'reserved',
|
if (row.reservedVariantId !== null) {
|
||||||
error: this.errorMessage(error, 'La entrada ya no está disponible.'),
|
this.restoreReservedRowAfterError(rowId, row, message);
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.patchRow(rowId, { variantId: null, status: 'error', error: message });
|
||||||
|
this.toastService.danger(message);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.reservationRequests.set(rowId, request);
|
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 {
|
private applySummary(response: CatalogVariantOptionsResponse, row: TicketSelectionRow): void {
|
||||||
this.availableVariantCount.set(
|
this.availableVariantCount.set(
|
||||||
Math.max(0, response.available_variant_count - (row.cartItemId === null ? 0 : 1)),
|
Math.max(0, response.available_variant_count - (row.cartItemId === null ? 0 : 1)),
|
||||||
@@ -385,6 +460,8 @@ export class ProductTicketSelectorComponent {
|
|||||||
cartItemId: null,
|
cartItemId: null,
|
||||||
selectedValues: {},
|
selectedValues: {},
|
||||||
selectors: [],
|
selectors: [],
|
||||||
|
reservedSelectedValues: {},
|
||||||
|
reservedSelectors: [],
|
||||||
status: 'checking',
|
status: 'checking',
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
@@ -396,13 +473,17 @@ export class ProductTicketSelectorComponent {
|
|||||||
const variant = item.product?.variants?.find(({ id }) => id === item.variant_id);
|
const variant = item.product?.variants?.find(({ id }) => id === item.variant_id);
|
||||||
if (!variant) return null;
|
if (!variant) return null;
|
||||||
|
|
||||||
|
const selectedValues = this.normalizeCartValues(variant.values);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
variantId: item.variant_id,
|
variantId: item.variant_id,
|
||||||
reservedVariantId: item.variant_id,
|
reservedVariantId: item.variant_id,
|
||||||
cartItemId: item.id,
|
cartItemId: item.id,
|
||||||
selectedValues: this.normalizeCartValues(variant.values),
|
selectedValues,
|
||||||
selectors: [],
|
selectors: [],
|
||||||
|
reservedSelectedValues: selectedValues,
|
||||||
|
reservedSelectors: [],
|
||||||
status: 'checking',
|
status: 'checking',
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user