feat(variant-selector): implement manual selection when autoSelectFirst is disabled

This commit is contained in:
2026-08-14 09:26:43 -03:00
parent 34afb5142d
commit 0defe75adb
5 changed files with 101 additions and 14 deletions

View File

@@ -258,6 +258,15 @@ describe('ProductListComponent', () => {
expect(element.querySelector('.product-list--single')).not.toBeNull();
expect(element.querySelectorAll('app-product-ticket-selector')).toHaveLength(1);
expect(element.querySelectorAll('.variant-selector__select')).toHaveLength(4);
expect(
(element.querySelector('.ticket-selector__actions .btn-primary') as HTMLButtonElement)
.disabled,
).toBe(true);
const ticketSelector = fixture.debugElement.query(By.directive(ProductTicketSelectorComponent))
.componentInstance as ProductTicketSelectorComponent;
ticketSelector['updateRow'](1, 401);
fixture.detectChanges();
(element.querySelector('.ticket-selector__add-row-button') as HTMLButtonElement).click();
fixture.detectChanges();
@@ -265,6 +274,9 @@ describe('ProductListComponent', () => {
expect(element.querySelectorAll('.ticket-selector__row')).toHaveLength(2);
expect(element.querySelectorAll('.variant-selector__select')).toHaveLength(8);
ticketSelector['updateRow'](2, 402);
fixture.detectChanges();
(element.querySelector('.ticket-selector__actions .btn-primary') as HTMLButtonElement).click();
expect(buySpy).toHaveBeenCalledWith({

View File

@@ -26,6 +26,7 @@
class="ticket-selector__fields"
[variants]="variantsForRow(row.id)"
[disabled]="disabled()"
[autoSelectFirst]="false"
[selectedVariant]="row.variantId"
(selectedVariantChange)="updateRow(row.id, $event)"
/>
@@ -55,11 +56,7 @@
<div class="ticket-selector__actions row g-0 justify-content-end">
<div class="col-12 col-md-3">
<app-button
variant="primary"
[disabled]="disabled() || !hasSelection()"
(click)="onBuy()"
>
<app-button variant="primary" [disabled]="disabled() || !hasSelection()" (click)="onBuy()">
Comprar
</app-button>
</div>

View File

@@ -4,11 +4,14 @@
<select
class="form-select variant-selector__select"
[attr.aria-label]="selector.label"
[disabled]="disabled()"
[disabled]="disabled() || (!autoSelectFirst() && selector.options.length === 0)"
[compareWith]="compareValues"
[ngModel]="selectedValues()[selector.key]"
[ngModel]="selectedValues()[selector.key] ?? null"
(ngModelChange)="onValueChange(selector.key, $event)"
>
@if (!autoSelectFirst()) {
<option [ngValue]="null" disabled>Seleccioná {{ selector.label }}</option>
}
@for (option of selector.options; track option.key) {
<option [ngValue]="option.value">{{ option.label }}</option>
}

View File

@@ -79,4 +79,39 @@ describe('VariantSelectorComponent', () => {
expect(fixture.componentInstance.selectedVariant()).toBe(1);
});
it('requires manual selections when autoSelectFirst is disabled', async () => {
await TestBed.configureTestingModule({
imports: [VariantSelectorComponent],
}).compileComponents();
const fixture = TestBed.createComponent(VariantSelectorComponent);
fixture.componentRef.setInput('variants', [
{ id: 1, values: { sector: 'A', asiento: '1' } },
{ id: 2, values: { sector: 'A', asiento: '2' } },
]);
fixture.componentRef.setInput('autoSelectFirst', false);
fixture.detectChanges();
expect(fixture.componentInstance.selectedVariant()).toBeNull();
expect((fixture.componentInstance as any).selectedValues()).toEqual({});
(fixture.componentInstance as any).onValueChange('sector', 'A');
fixture.detectChanges();
expect(fixture.componentInstance.selectedVariant()).toBeNull();
expect((fixture.componentInstance as any).selectedValues()).toEqual({ sector: 'A' });
fixture.componentRef.setInput('variants', [
{ id: 1, values: { sector: 'A', asiento: '1' } },
{ id: 2, values: { sector: 'A', asiento: '2' } },
]);
fixture.detectChanges();
expect((fixture.componentInstance as any).selectedValues()).toEqual({ sector: 'A' });
expect((fixture.componentInstance as any).selectors()[1].options).toHaveLength(2);
(fixture.componentInstance as any).onValueChange('asiento', '2');
expect(fixture.componentInstance.selectedVariant()).toBe(2);
});
});

View File

@@ -48,6 +48,7 @@ export class VariantSelectorComponent {
readonly selectedVariant = model<unknown>(null);
readonly disabled = input(false);
readonly compact = input(false);
readonly autoSelectFirst = input(true);
protected readonly selectedValues = signal<Record<string, VariantAttributeValue>>({});
protected readonly compareValues = (
@@ -82,6 +83,7 @@ export class VariantSelectorComponent {
effect(() => {
const variants = this.variants();
const selectedVariant = this.selectedVariant();
const autoSelectFirst = this.autoSelectFirst();
untracked(() => {
if (variants.length === 0) {
@@ -90,19 +92,31 @@ export class VariantSelectorComponent {
return;
}
const selected =
variants.find((variant) => Object.is(variant.id, selectedVariant)) ?? variants[0];
this.selectedValues.set({ ...selected.values });
if (!Object.is(selected.id, selectedVariant)) this.selectedVariant.set(selected.id);
const selected = variants.find((variant) => Object.is(variant.id, selectedVariant));
if (!selected && !autoSelectFirst) {
this.selectedValues.set(this.reconcileManualSelection(this.selectedValues(), variants));
if (selectedVariant !== null) this.selectedVariant.set(null);
return;
}
const resolvedSelection = selected ?? variants[0];
this.selectedValues.set({ ...resolvedSelection.values });
if (!Object.is(resolvedSelection.id, selectedVariant)) {
this.selectedVariant.set(resolvedSelection.id);
}
});
});
}
protected onValueChange(key: string, value: VariantAttributeValue): void {
protected onValueChange(key: string, value: VariantAttributeValue | null): void {
const variants = this.variants();
const keys = this.attributeKeys();
const changedIndex = keys.indexOf(key);
const values = { ...this.selectedValues(), [key]: value };
const values = { ...this.selectedValues() };
if (value === null) delete values[key];
else values[key] = value;
for (let index = changedIndex + 1; index < keys.length; index++) {
const currentKey = keys[index];
@@ -116,7 +130,7 @@ export class VariantSelectorComponent {
if (!options.some((option) => this.sameValue(option.value, values[currentKey]))) {
const firstOption = options[0];
if (firstOption) values[currentKey] = firstOption.value;
if (firstOption && this.autoSelectFirst()) values[currentKey] = firstOption.value;
else delete values[currentKey];
}
}
@@ -151,6 +165,32 @@ export class VariantSelectorComponent {
return Array.from(options.values());
}
private reconcileManualSelection(
selectedValues: Record<string, VariantAttributeValue>,
variants: VariantSelectorVariant[],
): Record<string, VariantAttributeValue> {
const reconciled: Record<string, VariantAttributeValue> = {};
for (const key of this.attributeKeys()) {
const selectedValue = selectedValues[key];
if (selectedValue === undefined) break;
const compatibleVariants = variants.filter((variant) =>
Object.entries(reconciled).every(([previousKey, previousValue]) =>
this.sameValue(variant.values[previousKey], previousValue),
),
);
const selectionIsAvailable = this.optionsFor(compatibleVariants, key).some((option) =>
this.sameValue(option.value, selectedValue),
);
if (!selectionIsAvailable) break;
reconciled[key] = selectedValue;
}
return reconciled;
}
private sameValue(
left: VariantAttributeValue | undefined,
right: VariantAttributeValue | undefined,