feat(ticket-selector): implement confirmation modal for row deletion and refactor selection handling
This commit is contained in:
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user