Skip to content

Commit

Permalink
fix: prevent double requests after inserting a SKU on direct order an…
Browse files Browse the repository at this point in the history
…d quickorder form including validation
  • Loading branch information
Carola Bratsch authored and shauke committed Aug 31, 2021
1 parent 385a855 commit fb2e192
Show file tree
Hide file tree
Showing 6 changed files with 107 additions and 89 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ <h3>{{ 'shopping_cart.direct_order.heading' | translate }}</h3>
type="submit"
class="btn btn-primary"
[disabled]="
directOrderForm.pristine || this.directOrderForm.invalid || (hasQuantityError$ | async) || loading
directOrderForm.pristine ||
this.directOrderForm.invalid ||
(hasQuantityError$ | async) ||
(loading$ | async)
"
>
{{ 'quickorder.page.add.cart' | translate }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { instance, mock, when } from 'ts-mockito';

import { CheckoutFacade } from 'ish-core/facades/checkout.facade';
import { ProductContextFacade } from 'ish-core/facades/product-context.facade';
import { ShoppingFacade } from 'ish-core/facades/shopping.facade';
import { ProductQuantityComponent } from 'ish-shared/components/product/product-quantity/product-quantity.component';
import { FormlyTestingModule } from 'ish-shared/formly/dev/testing/formly-testing.module';

Expand All @@ -22,17 +21,15 @@ describe('Direct Order Component', () => {
beforeEach(async () => {
context = mock(ProductContextFacade);
when(context.select('quantity')).thenReturn(EMPTY);
when(context.select('product')).thenReturn(EMPTY);

checkoutFacade = mock(CheckoutFacade);
when(checkoutFacade.basketMaxItemQuantity$).thenReturn(of(100));

await TestBed.configureTestingModule({
imports: [FormlyTestingModule, TranslateModule.forRoot()],
declarations: [DirectOrderComponent, MockComponent(ProductQuantityComponent)],
providers: [
{ provide: CheckoutFacade, useFactory: () => instance(checkoutFacade) },
{ provide: ShoppingFacade, useFactory: () => instance(mock(ShoppingFacade)) },
],
providers: [{ provide: CheckoutFacade, useFactory: () => instance(checkoutFacade) }],
})
.overrideComponent(DirectOrderComponent, {
set: { providers: [{ provide: ProductContextFacade, useFactory: () => instance(context) }] },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,33 @@
import { AfterViewInit, ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { AfterViewInit, ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { FormlyFieldConfig } from '@ngx-formly/core';
import { TranslateService } from '@ngx-translate/core';
import { Observable, Subject } from 'rxjs';
import { debounceTime, switchMap, takeUntil, tap } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { debounceTime, map, tap } from 'rxjs/operators';

import { CheckoutFacade } from 'ish-core/facades/checkout.facade';
import { ProductContextFacade } from 'ish-core/facades/product-context.facade';
import { ShoppingFacade } from 'ish-core/facades/shopping.facade';
import { ProductCompletenessLevel, ProductHelper } from 'ish-core/models/product/product.helper';
import { GenerateLazyComponent } from 'ish-core/utils/module-loader/generate-lazy-component.decorator';
import { whenTruthy } from 'ish-core/utils/operators';

/**
* The Direct Order Component displays a form to insert a product sku and quantity to add it to the cart.
*/
@Component({
selector: 'ish-direct-order',
templateUrl: './direct-order.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [ProductContextFacade],
})
@GenerateLazyComponent()
export class DirectOrderComponent implements OnInit, OnDestroy, AfterViewInit {
export class DirectOrderComponent implements OnInit, AfterViewInit {
directOrderForm = new FormGroup({});
fields: FormlyFieldConfig[];
model = { sku: '' };

hasQuantityError$: Observable<boolean>;
loading = false;

private destroy$ = new Subject();
loading$: Observable<boolean>;

constructor(
private shoppingFacade: ShoppingFacade,
private checkoutFacade: CheckoutFacade,
private translate: TranslateService,
private context: ProductContextFacade
Expand All @@ -43,14 +40,26 @@ export class DirectOrderComponent implements OnInit, OnDestroy, AfterViewInit {
this.context.config = { quantity: true };
}

/**
* Set the form control field to the product context and handle its behavior.
*/
ngAfterViewInit() {
this.context.connect('sku', this.directOrderForm.get('sku').valueChanges);
this.context.connect('maxQuantity', this.checkoutFacade.basketMaxItemQuantity$);
}
this.context.connect(
'sku',
this.directOrderForm.get('sku').valueChanges.pipe(
tap(() => this.context.set('loading', () => true)),
debounceTime(500)
)
);
const skuControl = this.directOrderForm.get('sku');
skuControl.setAsyncValidators(() =>
this.context
.select('product')
.pipe(map(product => (product.failed && skuControl.value.trim !== '' ? { validProduct: false } : undefined)))
);

ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
this.context.connect('maxQuantity', this.checkoutFacade.basketMaxItemQuantity$);
this.loading$ = this.context.select('loading');
}

onSubmit() {
Expand All @@ -69,27 +78,9 @@ export class DirectOrderComponent implements OnInit, OnDestroy, AfterViewInit {
placeholder: 'shopping_cart.direct_order.item_placeholder',
attributes: { autocomplete: 'on' },
},
asyncValidators: {
validProduct: {
expression: (control: FormControl) =>
control.valueChanges.pipe(
tap(sku => {
if (!sku) {
control.setErrors(undefined);
} else {
this.loading = true;
}
}),
whenTruthy(),
debounceTime(500),
switchMap(() => this.shoppingFacade.product$(control.value, ProductCompletenessLevel.List)),
tap(product => {
control.setErrors(ProductHelper.isFailedLoading(product) ? { validProduct: false } : undefined);
this.loading = false;
}),
takeUntil(this.destroy$)
),
message: () => this.translate.get('quickorder.page.error.invalid.product', { 0: this.model.sku }),
validation: {
messages: {
validProduct: () => this.translate.get('quickorder.page.error.invalid.product', { 0: this.model.sku }),
},
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { FormlyFieldConfig, FormlyFormOptions } from '@ngx-formly/core';
import { TranslateService } from '@ngx-translate/core';
import { Subject } from 'rxjs';
import { debounceTime, switchMap, takeUntil, tap } from 'rxjs/operators';

import { ShoppingFacade } from 'ish-core/facades/shopping.facade';
import { ProductCompletenessLevel, ProductHelper, SkuQuantityType } from 'ish-core/models/product/product.helper';
import { SkuQuantityType } from 'ish-core/models/product/product.helper';

/**
* The Quick Add Products Component displays a form to insert multiple product sku and quantity to add them to the cart.
*/
@Component({
selector: 'ish-quickorder-add-products-form',
templateUrl: './quickorder-add-products-form.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
changeDetection: ChangeDetectionStrategy.Default,
})
export class QuickorderAddProductsFormComponent implements OnInit, OnDestroy {
export class QuickorderAddProductsFormComponent implements OnInit {
quickOrderForm: FormGroup = new FormGroup({});
model: { addProducts: SkuQuantityType[] } = { addProducts: [] };
options: FormlyFormOptions = {};
fields: FormlyFieldConfig[];

private destroy$ = new Subject();

numberOfRows = 5;

constructor(private translate: TranslateService, private shoppingFacade: ShoppingFacade) {}
Expand Down Expand Up @@ -54,7 +53,7 @@ export class QuickorderAddProductsFormComponent implements OnInit, OnDestroy {

/**
* returns the field with a repeating type
* repeat contains the representing of the form with form fields and links to add and remove lines
* repeat contains the representing of the form with form fields and links to add and remove lines (check quickorder-repeat-form.component for more information)
* the field array give the input field with validators of the sku for each line (which are represented by the objects of the model array)
*/
private getFields(): FormlyFieldConfig[] {
Expand All @@ -78,35 +77,16 @@ export class QuickorderAddProductsFormComponent implements OnInit, OnDestroy {
fieldClass: 'col-12',
placeholder: 'shopping_cart.direct_order.item_placeholder',
},
asyncValidators: {
validProduct: {
expression: (control: FormControl) =>
control.valueChanges.pipe(
tap(sku => {
if (!sku) {
control.setErrors(undefined);
}
}),
debounceTime(500),
switchMap(() => this.shoppingFacade.product$(control.value, ProductCompletenessLevel.List)),
tap(product => {
const failed = ProductHelper.isFailedLoading(product);
control.setErrors(failed ? { validProduct: false } : undefined);
}),
takeUntil(this.destroy$)
),
message: (_: unknown, field: FormlyFieldConfig) =>
this.translate.get('quickorder.page.error.invalid.product', {
0: this.model.addProducts[parseInt(field.parent.key.toString(), 10)].sku,
}),
},
},
expressionProperties: {
'templateOptions.required': (control: SkuQuantityType) => !!control.quantity,
},
validation: {
messages: {
required: 'quickorder.page.quantityWithoutSKU',
validProduct: (_, field) =>
this.translate.get('quickorder.page.error.invalid.product', {
0: this.model.addProducts[parseInt(field.parent.key.toString(), 10)].sku,
}),
},
},
},
Expand All @@ -115,9 +95,4 @@ export class QuickorderAddProductsFormComponent implements OnInit, OnDestroy {
},
];
}

ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,23 @@
class="row"
[attr.data-testing-id]="'quickorder-line-' + i"
ishProductContext
[(sku)]="model[i].sku"
[(quantity)]="model[i].quantity"
[configuration]="{ quantity: true }"
>
<formly-field class="col-6" [field]="field"></formly-field>
<ish-product-quantity class="col-3 col-sm-2 quickorder-line-item"></ish-product-quantity>
<div class="col-3 col-sm-2 d-flex quickorder-line-item">
<a class="btn btn-tool" (click)="remove(i)" title="{{ 'quickorder.page.remove.row' | translate }}">
<a
class="btn btn-tool"
(click)="remove(i); updateContexts()"
title="{{ 'quickorder.page.remove.row' | translate }}"
>
<fa-icon [icon]="['fas', 'trash-alt']"></fa-icon>
</a>
</div>
</div>
<div class="section d-flex flex-nowrap">
<a (click)="add()" data-testing-id="add-quickorder-line">{{ to?.addText | translate }}</a>
<a (click)="add(); updateContexts()" data-testing-id="add-quickorder-line">{{ to?.addText | translate }}</a>
<span class="link-separator"></span>
<a (click)="addMultipleRows(to?.numberMoreRows)">{{ to?.addMoreText | translate: { '0': to?.numberMoreRows } }}</a>
</div>
Original file line number Diff line number Diff line change
@@ -1,15 +1,64 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
QueryList,
ViewChildren,
} from '@angular/core';
import { FieldArrayType } from '@ngx-formly/core';
import { debounceTime, map } from 'rxjs/operators';

import { ProductContextDirective } from 'ish-core/directives/product-context.directive';
import { ProductContextFacade } from 'ish-core/facades/product-context.facade';

/**
* The Quick Order Repeat Component provides a formly field element, which displays a field array of n elements depending on the according model.
* Each line displays a form field for the sku and for the according quantity.
* The component handles adding and removing of elements to the according formly model.
* The component controls the product contexts for a proper behavior between entered product sku and its quantity field.
*/
@Component({
selector: 'ish-quickorder-repeat-form',
templateUrl: './quickorder-repeat-form.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class QuickorderRepeatFormComponent extends FieldArrayType {
export class QuickorderRepeatFormComponent extends FieldArrayType implements AfterViewInit {
@ViewChildren(ProductContextDirective) contexts: QueryList<{ context: ProductContextFacade }>;

constructor(private cdRef: ChangeDetectorRef) {
super();
}

ngAfterViewInit() {
this.updateContexts();
}

addMultipleRows(rows: number) {
for (let i = 0; i < rows; i++) {
this.add(this.model.length, { sku: '', quantity: undefined });
this.add(this.model.length, { sku: '', quantity: 1 });
}
this.updateContexts();
}

/**
* Set the form control field to the according product context and handle its behavior.
*/
updateContexts() {
this.cdRef.detectChanges();
this.contexts.forEach((context: { context: ProductContextFacade }, index) => {
const field = this.field.fieldGroup[index].fieldGroup[0];
const formControl = field.formControl;

context.context.connect('sku', formControl.valueChanges.pipe(debounceTime(500)));
formControl.setAsyncValidators(() =>
context.context.select('product').pipe(
map(product => {
this.cdRef.markForCheck();
return product.failed && formControl.value.trim !== '' ? { validProduct: false } : undefined;
})
)
);
});
}
}

0 comments on commit fb2e192

Please sign in to comment.