feat(ticket-selector): implement confirmation modal for row deletion and refactor selection handling

This commit is contained in:
2026-08-14 14:06:43 -03:00
parent 1b94b2ca13
commit e4670491c7
3 changed files with 78 additions and 14 deletions

View File

@@ -52,13 +52,12 @@
class="form-select variant-selector__select"
[attr.aria-label]="selector.label"
[disabled]="rowDisabled(row) || !selector.enabled"
[compareWith]="compareValues"
[ngModel]="selectedOption(row, selector.key)"
(ngModelChange)="onSelectionChange(row.id, selector.key, $event)"
[value]="selectedOptionKey(row, selector.key)"
(change)="onSelectionKeyChange(row.id, selector, $any($event.target).value)"
>
<option [ngValue]="null" disabled>Seleccioná {{ selector.label }}</option>
<option value="" disabled>Seleccioná {{ selector.label }}</option>
@for (option of selector.options; track $index) {
<option [ngValue]="option">{{ optionLabel(option) }}</option>
<option [value]="optionKey(option)">{{ optionLabel(option) }}</option>
}
</select>
}

View File

@@ -7,6 +7,7 @@ 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 { ProductTicketSelectorComponent } from './product-ticket-selector.component';
describe('ProductTicketSelectorComponent', () => {
@@ -21,6 +22,18 @@ describe('ProductTicketSelectorComponent', () => {
afterEach(() => TestBed.resetTestingModule());
it('checks partial selections and reserves the resolved variant in the cart', async () => {
const openConfirmDelete = vi.fn().mockReturnValue(of(true));
const removeItem = vi.fn().mockReturnValue(
of({
data: {
id: 10,
tenant_codigo: 'demo',
status: 'active',
subtotal: '0.00',
items: [],
},
}),
);
const resolvedVariant = {
id: 401,
precio: '10000.00',
@@ -101,6 +114,7 @@ describe('ProductTicketSelectorComponent', () => {
},
}),
),
removeItem,
};
catalogService.withoutLoading.mockReturnValue(catalogService);
cartService.withoutLoading.mockReturnValue(cartService);
@@ -109,6 +123,7 @@ describe('ProductTicketSelectorComponent', () => {
providers: [
{ provide: CatalogService, useValue: catalogService },
{ provide: CartService, useValue: cartService },
{ provide: ModalService, useValue: { openConfirmDelete } },
],
}).compileComponents();
const fixture = TestBed.createComponent(ProductTicketSelectorComponent);
@@ -148,6 +163,23 @@ describe('ProductTicketSelectorComponent', () => {
expect(validation.querySelector('.fa-check')).not.toBeNull();
expect(fixture.nativeElement.querySelector('.ticket-selector__row-status')).toBeNull();
expect(fixture.nativeElement.querySelector('.ticket-selector__row-error')).toBeNull();
const deleteButton = fixture.nativeElement.querySelector(
'app-icon-button button',
) as HTMLButtonElement;
expect(deleteButton.classList.contains('icon-btn--bordered')).toBe(true);
deleteButton.click();
expect(openConfirmDelete).toHaveBeenCalledWith({
title: 'Eliminar entrada',
content:
'Se eliminará esta entrada de “Entrada”. Si ya estaba reservada, se liberará del carrito.',
confirmLabel: 'Sí, eliminar',
cancelLabel: 'Cancelar',
size: 'md',
});
expect(removeItem).toHaveBeenCalledWith(25);
expect(fixture.componentInstance['rows']()).toHaveLength(0);
});
it('restores selections that are already reserved in the cart', async () => {

View File

@@ -12,11 +12,12 @@ import {
signal,
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
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 {
CatalogVariantOptionsResponse,
CatalogVariantSelector,
@@ -49,7 +50,7 @@ interface TicketSelectionRow {
@Component({
selector: 'app-product-ticket-selector',
imports: [ButtonComponent, IconButtonComponent, FormsModule],
imports: [ButtonComponent, IconButtonComponent],
templateUrl: './product-ticket-selector.component.html',
styleUrl: './product-ticket-selector.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -57,6 +58,7 @@ interface TicketSelectionRow {
export class ProductTicketSelectorComponent {
private readonly catalogService = inject(CatalogService);
private readonly cartService = inject(CartService);
private readonly modalService = inject(ModalService);
private readonly destroyRef = inject(DestroyRef);
private readonly optionRequests = new Map<number, Subscription>();
private readonly reservationRequests = new Map<number, Subscription>();
@@ -160,6 +162,23 @@ export class ProductTicketSelectorComponent {
const row = this.findRow(rowId);
if (!row || this.isBusy(row)) return;
this.modalService
.openConfirmDelete({
title: 'Eliminar entrada',
content: `Se eliminará esta entrada de “${this.title()}”. Si ya estaba reservada, se liberará del carrito.`,
confirmLabel: 'Sí, eliminar',
cancelLabel: 'Cancelar',
size: 'md',
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((confirmed) => {
if (confirmed) this.confirmRemoveRow(row);
});
}
private confirmRemoveRow(row: TicketSelectionRow): void {
const rowId = row.id;
this.optionRequests.get(rowId)?.unsubscribe();
if (row.cartItemId === null) {
@@ -206,13 +225,6 @@ export class ProductTicketSelectorComponent {
this.loadOptions(rowId, selectedValues);
}
protected readonly compareValues = (
left: CatalogVariantValue | null,
right: CatalogVariantValue | null,
): boolean => {
return left !== null && right !== null && this.valueKey(left) === this.valueKey(right);
};
protected optionLabel(value: CatalogVariantValue): string {
const values = Array.isArray(value) ? value : [value];
return values.map((item) => (typeof item === 'string' ? item : item.label)).join(', ');
@@ -222,6 +234,27 @@ export class ProductTicketSelectorComponent {
return row.selectedValues[key] ?? null;
}
protected optionKey(value: CatalogVariantValue): string {
return this.valueKey(value);
}
protected selectedOptionKey(row: TicketSelectionRow, key: string): string {
const selectedOption = this.selectedOption(row, key);
return selectedOption === null ? '' : this.valueKey(selectedOption);
}
protected onSelectionKeyChange(
rowId: number,
selector: CatalogVariantSelector,
selectedKey: string,
): void {
const selectedOption =
selector.options.find((option) => this.valueKey(option) === selectedKey) ?? null;
this.onSelectionChange(rowId, selector.key, selectedOption);
}
protected rowDisabled(row: TicketSelectionRow): boolean {
return this.disabled() || this.isBusy(row);
}