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"
|
class="form-select variant-selector__select"
|
||||||
[attr.aria-label]="selector.label"
|
[attr.aria-label]="selector.label"
|
||||||
[disabled]="rowDisabled(row) || !selector.enabled"
|
[disabled]="rowDisabled(row) || !selector.enabled"
|
||||||
[compareWith]="compareValues"
|
[value]="selectedOptionKey(row, selector.key)"
|
||||||
[ngModel]="selectedOption(row, selector.key)"
|
(change)="onSelectionKeyChange(row.id, selector, $any($event.target).value)"
|
||||||
(ngModelChange)="onSelectionChange(row.id, selector.key, $event)"
|
|
||||||
>
|
>
|
||||||
<option [ngValue]="null" disabled>Seleccioná {{ selector.label }}</option>
|
<option value="" disabled>Seleccioná {{ selector.label }}</option>
|
||||||
@for (option of selector.options; track $index) {
|
@for (option of selector.options; track $index) {
|
||||||
<option [ngValue]="option">{{ optionLabel(option) }}</option>
|
<option [value]="optionKey(option)">{{ optionLabel(option) }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 { ProductTicketSelectorComponent } from './product-ticket-selector.component';
|
import { ProductTicketSelectorComponent } from './product-ticket-selector.component';
|
||||||
|
|
||||||
describe('ProductTicketSelectorComponent', () => {
|
describe('ProductTicketSelectorComponent', () => {
|
||||||
@@ -21,6 +22,18 @@ describe('ProductTicketSelectorComponent', () => {
|
|||||||
afterEach(() => TestBed.resetTestingModule());
|
afterEach(() => TestBed.resetTestingModule());
|
||||||
|
|
||||||
it('checks partial selections and reserves the resolved variant in the cart', async () => {
|
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 = {
|
const resolvedVariant = {
|
||||||
id: 401,
|
id: 401,
|
||||||
precio: '10000.00',
|
precio: '10000.00',
|
||||||
@@ -101,6 +114,7 @@ describe('ProductTicketSelectorComponent', () => {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
removeItem,
|
||||||
};
|
};
|
||||||
catalogService.withoutLoading.mockReturnValue(catalogService);
|
catalogService.withoutLoading.mockReturnValue(catalogService);
|
||||||
cartService.withoutLoading.mockReturnValue(cartService);
|
cartService.withoutLoading.mockReturnValue(cartService);
|
||||||
@@ -109,6 +123,7 @@ describe('ProductTicketSelectorComponent', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: CatalogService, useValue: catalogService },
|
{ provide: CatalogService, useValue: catalogService },
|
||||||
{ provide: CartService, useValue: cartService },
|
{ provide: CartService, useValue: cartService },
|
||||||
|
{ provide: ModalService, useValue: { openConfirmDelete } },
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
const fixture = TestBed.createComponent(ProductTicketSelectorComponent);
|
const fixture = TestBed.createComponent(ProductTicketSelectorComponent);
|
||||||
@@ -148,6 +163,23 @@ describe('ProductTicketSelectorComponent', () => {
|
|||||||
expect(validation.querySelector('.fa-check')).not.toBeNull();
|
expect(validation.querySelector('.fa-check')).not.toBeNull();
|
||||||
expect(fixture.nativeElement.querySelector('.ticket-selector__row-status')).toBeNull();
|
expect(fixture.nativeElement.querySelector('.ticket-selector__row-status')).toBeNull();
|
||||||
expect(fixture.nativeElement.querySelector('.ticket-selector__row-error')).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 () => {
|
it('restores selections that are already reserved in the cart', async () => {
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
untracked,
|
untracked,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { Subscription } from 'rxjs';
|
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 {
|
import {
|
||||||
CatalogVariantOptionsResponse,
|
CatalogVariantOptionsResponse,
|
||||||
CatalogVariantSelector,
|
CatalogVariantSelector,
|
||||||
@@ -49,7 +50,7 @@ interface TicketSelectionRow {
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-product-ticket-selector',
|
selector: 'app-product-ticket-selector',
|
||||||
imports: [ButtonComponent, IconButtonComponent, FormsModule],
|
imports: [ButtonComponent, IconButtonComponent],
|
||||||
templateUrl: './product-ticket-selector.component.html',
|
templateUrl: './product-ticket-selector.component.html',
|
||||||
styleUrl: './product-ticket-selector.component.scss',
|
styleUrl: './product-ticket-selector.component.scss',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -57,6 +58,7 @@ interface TicketSelectionRow {
|
|||||||
export class ProductTicketSelectorComponent {
|
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 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>();
|
||||||
@@ -160,6 +162,23 @@ export class ProductTicketSelectorComponent {
|
|||||||
const row = this.findRow(rowId);
|
const row = this.findRow(rowId);
|
||||||
if (!row || this.isBusy(row)) return;
|
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();
|
this.optionRequests.get(rowId)?.unsubscribe();
|
||||||
|
|
||||||
if (row.cartItemId === null) {
|
if (row.cartItemId === null) {
|
||||||
@@ -206,13 +225,6 @@ export class ProductTicketSelectorComponent {
|
|||||||
this.loadOptions(rowId, selectedValues);
|
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 {
|
protected optionLabel(value: CatalogVariantValue): string {
|
||||||
const values = Array.isArray(value) ? value : [value];
|
const values = Array.isArray(value) ? value : [value];
|
||||||
return values.map((item) => (typeof item === 'string' ? item : item.label)).join(', ');
|
return values.map((item) => (typeof item === 'string' ? item : item.label)).join(', ');
|
||||||
@@ -222,6 +234,27 @@ export class ProductTicketSelectorComponent {
|
|||||||
return row.selectedValues[key] ?? null;
|
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 {
|
protected rowDisabled(row: TicketSelectionRow): boolean {
|
||||||
return this.disabled() || this.isBusy(row);
|
return this.disabled() || this.isBusy(row);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user