Skip to content

Commit

Permalink
feat: checkout - simple basket acceleration (#479)
Browse files Browse the repository at this point in the history
  • Loading branch information
SGrueber authored Dec 18, 2020
1 parent 5f046c1 commit ff08159
Show file tree
Hide file tree
Showing 7 changed files with 290 additions and 68 deletions.
5 changes: 5 additions & 0 deletions src/app/core/facades/checkout.facade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
removePromotionCodeFromBasket,
setBasketAttribute,
setBasketPayment,
startCheckout,
updateBasketAddress,
updateBasketItems,
updateBasketShippingMethod,
Expand All @@ -54,6 +55,10 @@ export class CheckoutFacade {

checkoutStep$ = this.store.pipe(select(selectRouteData<number>('checkoutStep')));

start() {
this.store.dispatch(startCheckout());
}

continue(targetStep: number) {
this.store.dispatch(continueCheckout({ targetStep }));
}
Expand Down
150 changes: 150 additions & 0 deletions src/app/core/store/customer/basket/basket-validation.effects.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { BasketService } from 'ish-core/services/basket/basket.service';
import { CoreStoreModule } from 'ish-core/store/core/core-store.module';
import { CustomerStoreModule } from 'ish-core/store/customer/customer-store.module';
import { createOrder } from 'ish-core/store/customer/orders';
import { GeneralStoreModule } from 'ish-core/store/general/general-store.module';
import { loadServerConfigSuccess } from 'ish-core/store/general/server-config';
import { loadProductSuccess } from 'ish-core/store/shopping/products';
import { makeHttpError } from 'ish-core/utils/dev/api-service-utils';
import { BasketMockData } from 'ish-core/utils/dev/basket-mock-data';
Expand All @@ -25,6 +27,9 @@ import {
continueCheckoutSuccess,
continueCheckoutWithIssues,
loadBasketSuccess,
startCheckout,
startCheckoutFail,
startCheckoutSuccess,
submitBasket,
validateBasket,
} from './basket.actions';
Expand All @@ -47,8 +52,10 @@ describe('Basket Validation Effects', () => {
imports: [
CoreStoreModule.forTesting(),
CustomerStoreModule.forTesting('user', 'basket'),
GeneralStoreModule.forTesting('serverConfig'),
RouterTestingModule.withRoutes([
{ path: 'checkout', children: [{ path: 'address', component: DummyComponent }] },
{ path: 'checkout', children: [{ path: 'review', component: DummyComponent }] },
]),
],
providers: [
Expand All @@ -63,6 +70,149 @@ describe('Basket Validation Effects', () => {
location = TestBed.inject(Location);
});

describe('startCheckout$', () => {
const basketValidation: BasketValidation = {
basket: BasketMockData.getBasket(),
results: {
valid: true,
adjusted: false,
},
};

beforeEach(() => {
when(basketServiceMock.validateBasket(anything())).thenReturn(of(basketValidation));
store$.dispatch(
loadServerConfigSuccess({
config: { basket: { acceleration: true } },
})
);

store$.dispatch(
loadBasketSuccess({
basket: BasketMockData.getBasket(),
})
);
store$.dispatch(loadProductSuccess({ product: { sku: 'SKU' } as Product }));
});

it('should map to action of type ContinueCheckout without basket acceleration', () => {
store$.dispatch(
loadServerConfigSuccess({
config: { basket: { acceleration: false } },
})
);

const action = startCheckout();
const completion = continueCheckout({
targetStep: 1,
});
actions$ = hot('-a', { a: action });
const expected$ = cold('-c', { c: completion });

expect(effects.startCheckoutWithoutAcceleration$).toBeObservable(expected$);
});

it('should call the basketService for startCheckoutWithAcceleration', done => {
const action = startCheckout();
actions$ = of(action);

effects.startCheckoutWithAcceleration$.subscribe(() => {
verify(basketServiceMock.validateBasket(anything())).once();
done();
});
});

it('should map invalid request to action of type startCheckoutFail', () => {
when(basketServiceMock.validateBasket(anything())).thenReturn(throwError(makeHttpError({ message: 'invalid' })));

const action = startCheckout();
const completion = startCheckoutFail({ error: makeHttpError({ message: 'invalid' }) });
actions$ = hot('-a', { a: action });
const expected$ = cold('-c', { c: completion });

expect(effects.startCheckoutWithAcceleration$).toBeObservable(expected$);
});

it('should map a valid request to action of type startCheckoutSuccess', () => {
const action = startCheckout();

const completion = startCheckoutSuccess({
targetRoute: undefined,
basketValidation,
});
actions$ = hot('-a', { a: action });
const expected$ = cold('-c', { c: completion });

expect(effects.startCheckoutWithAcceleration$).toBeObservable(expected$);
});
});

describe('continueCheckoutWithAcceleration$', () => {
const basketValidation: BasketValidation = {
basket: BasketMockData.getBasket(),
results: {
valid: true,
adjusted: false,
},
};

beforeEach(() => {
when(basketServiceMock.validateBasket(anything())).thenReturn(of(basketValidation));
store$.dispatch(
loadServerConfigSuccess({
config: { basket: { acceleration: true } },
})
);

store$.dispatch(
loadBasketSuccess({
basket: BasketMockData.getBasket(),
})
);
store$.dispatch(loadProductSuccess({ product: { sku: 'SKU' } as Product }));
});

it('should call the basketService if validation results are valid and not adjusted', done => {
const action = startCheckoutSuccess({ basketValidation });
actions$ = of(action);

effects.continueCheckoutWithAcceleration$.subscribe(() => {
verify(basketServiceMock.validateBasket(anything())).once();
done();
});
});

it('should not call the basketService if validation results are invalid', done => {
const action = startCheckoutSuccess({
basketValidation: { ...basketValidation, results: { valid: false, adjusted: false } },
});
actions$ = of(action);

effects.continueCheckoutWithAcceleration$.subscribe(
() => {
verify(basketServiceMock.validateBasket(anything())).never();
},
fail,
done
);
});

it('should not call the basketService if validation results are adjusted', done => {
const action = startCheckoutSuccess({
basketValidation: { ...basketValidation, results: { valid: true, adjusted: true } },
});
actions$ = of(action);

effects.continueCheckoutWithAcceleration$.subscribe(
() => {
verify(basketServiceMock.validateBasket(anything())).never();
},
fail,
done
);
});
});

describe('validateBasket$', () => {
const basketValidation: BasketValidation = {
basket: BasketMockData.getBasket(),
Expand Down
145 changes: 84 additions & 61 deletions src/app/core/store/customer/basket/basket-validation.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store, select } from '@ngrx/store';
import { concatMap, filter, map, tap, withLatestFrom } from 'rxjs/operators';
import { intersection } from 'lodash-es';
import { concatMap, filter, map, mapTo, tap, withLatestFrom } from 'rxjs/operators';

import {
BasketValidationResultType,
BasketValidationScopeType,
} from 'ish-core/models/basket-validation/basket-validation.model';
import { BasketService } from 'ish-core/services/basket/basket.service';
import { createOrder } from 'ish-core/store/customer/orders';
import { getServerConfigParameter } from 'ish-core/store/general/server-config';
import { loadProduct } from 'ish-core/store/shopping/products';
import { mapErrorToAction, mapToPayload, mapToPayloadProperty, whenTruthy } from 'ish-core/utils/operators';

Expand All @@ -20,6 +22,9 @@ import {
continueCheckoutWithIssues,
loadBasketEligiblePaymentMethods,
loadBasketEligibleShippingMethods,
startCheckout,
startCheckoutFail,
startCheckoutSuccess,
submitBasket,
validateBasket,
} from './basket.actions';
Expand All @@ -29,11 +34,73 @@ import { getCurrentBasket } from './basket.selectors';
export class BasketValidationEffects {
constructor(
private actions$: Actions,
private router: Router,
private store: Store,
private router: Router,
private basketService: BasketService
) {}

private validationSteps: { scopes: BasketValidationScopeType[]; route: string }[] = [
{ scopes: ['Products', 'Value'], route: '/basket' },
{ scopes: ['InvoiceAddress', 'ShippingAddress', 'Addresses'], route: '/checkout/address' },
{ scopes: ['Shipping'], route: '/checkout/shipping' },
{ scopes: ['Payment'], route: '/checkout/payment' },
{ scopes: ['All'], route: '/checkout/review' },
{ scopes: ['All'], route: 'auto' }, // targetRoute will be calculated in dependence of the validation result
];

/**
* Jumps to the first checkout step (no basket acceleration)
*/
startCheckoutWithoutAcceleration$ = createEffect(() =>
this.actions$.pipe(
ofType(startCheckout),
withLatestFrom(this.store.pipe(select(getServerConfigParameter<boolean>('basket.acceleration')))),
filter(([, acc]) => !acc),
mapTo(continueCheckout({ targetStep: 1 }))
)
);

/**
* Check the basket before starting the basket acceleration
*/
startCheckoutWithAcceleration$ = createEffect(() =>
this.actions$.pipe(
ofType(startCheckout),
withLatestFrom(this.store.pipe(select(getServerConfigParameter<boolean>('basket.acceleration')))),
filter(([, acc]) => acc),
concatMap(() =>
this.basketService.validateBasket(this.validationSteps[0].scopes).pipe(
map(basketValidation => startCheckoutSuccess({ basketValidation })),
mapErrorToAction(startCheckoutFail)
)
)
)
);

/**
* Validates the basket and jumps to the next possible checkout step (basket acceleration)
*/
continueCheckoutWithAcceleration$ = createEffect(
() =>
this.actions$.pipe(
ofType(startCheckoutSuccess),
mapToPayload(),
map(payload => payload.basketValidation.results),
filter(results => results.valid && !results.adjusted),
concatMap(() =>
this.basketService.validateBasket(this.validationSteps[4].scopes).pipe(
tap(basketValidation => {
if (basketValidation?.results?.valid) {
this.router.navigate([this.validationSteps[4].route]);
}
this.jumpToTargetRoute('auto', basketValidation?.results);
})
)
)
),
{ dispatch: false }
);

/**
* validates the basket but doesn't change the route
*/
Expand Down Expand Up @@ -64,37 +131,9 @@ export class BasketValidationEffects {
mapToPayloadProperty('targetStep'),
whenTruthy(),
concatMap(targetStep => {
let scopes: BasketValidationScopeType[] = [''];
let targetRoute = '';
switch (targetStep) {
case 1: {
scopes = ['Products', 'Value'];
targetRoute = '/checkout/address';
break;
}
case 2: {
scopes = ['InvoiceAddress', 'ShippingAddress', 'Addresses'];
targetRoute = '/checkout/shipping';
break;
}
case 3: {
scopes = ['Shipping'];
targetRoute = '/checkout/payment';
break;
}
case 4: {
scopes = ['Payment'];
targetRoute = '/checkout/review';
break;
}
// before order creation the whole basket is validated again
case 5: {
scopes = ['All'];
targetRoute = 'auto'; // targetRoute will be calculated in dependence of the validation result
break;
}
}
return this.basketService.validateBasket(scopes).pipe(
const targetRoute = this.validationSteps[targetStep].route;

return this.basketService.validateBasket(this.validationSteps[targetStep - 1].scopes).pipe(
withLatestFrom(this.store.pipe(select(getCurrentBasket))),
concatMap(([basketValidation, basket]) =>
basketValidation.results.valid
Expand Down Expand Up @@ -153,38 +192,22 @@ export class BasketValidationEffects {
if (!targetRoute || !results) {
return;
}

if (targetRoute === 'auto') {
// determine where to go if basket validation finished with an issue before order creation
// consider the 1st error or info - maybe this logic can be enhanced later on
let scope =
results.errors && results.errors.find(issue => issue.parameters && issue.parameters.scopes).parameters.scopes;
if (!scope) {
scope =
results.infos && results.infos.find(issue => issue.parameters && issue.parameters.scopes).parameters.scopes;
let scopes = [];
results.errors?.forEach(error => (scopes = scopes.concat(error?.parameters?.scopes)));
if (!scopes?.length) {
results.infos?.forEach(info => (scopes = scopes.concat(info?.parameters?.scopes)));
}

switch (scope) {
case 'Products':
case 'Value': {
this.router.navigate(['/basket'], { queryParams: { error: true } });
break;
}
case 'Addresses':
case 'ShippingAddress':
case 'InvoiceAddress': {
this.router.navigate(['/checkout/address'], { queryParams: { error: true } });
break;
this.validationSteps.every(step => {
if (intersection(step.scopes, scopes)?.length) {
this.router.navigate([step.route], { queryParams: { error: true } });
return false;
}
case 'Shipping': {
this.router.navigate(['/checkout/shipping'], { queryParams: { error: true } });
break;
}
case 'Payment': {
this.router.navigate(['/checkout/payment'], { queryParams: { error: true } });
break;
}
// otherwise stay on the current page
}
return true;
});
// otherwise stay on the current page
} else if (results.valid && !results.adjusted) {
this.router.navigate([targetRoute]);
}
Expand Down
Loading

0 comments on commit ff08159

Please sign in to comment.