diff --git a/src/app/core/pipes.module.ts b/src/app/core/pipes.module.ts index 298c51d9c1..b5c25ffee0 100644 --- a/src/app/core/pipes.module.ts +++ b/src/app/core/pipes.module.ts @@ -6,9 +6,9 @@ import { CategoryRoutePipe } from './pipes/category-route.pipe'; import { DatePipe } from './pipes/date.pipe'; import { HighlightPipe } from './pipes/highlight.pipe'; import { MakeHrefPipe } from './pipes/make-href.pipe'; -import { ProductRoutePipe } from './pipes/product-route.pipe'; import { SafeHtmlPipe } from './pipes/safe-html.pipe'; import { SanitizePipe } from './pipes/sanitize.pipe'; +import { ProductRoutePipe } from './routing/product/product-route.pipe'; const pipes = [ AttributeToStringPipe, diff --git a/src/app/core/pipes/product-route.pipe.spec.ts b/src/app/core/pipes/product-route.pipe.spec.ts deleted file mode 100644 index 76e9896191..0000000000 --- a/src/app/core/pipes/product-route.pipe.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import * as using from 'jasmine-data-provider'; - -import { ProductRoutePipe } from './product-route.pipe'; - -describe('Product Route Pipe', () => { - let productRoutePipe: ProductRoutePipe; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ProductRoutePipe], - }); - productRoutePipe = TestBed.get(ProductRoutePipe); - }); - - it('should be created', () => { - expect(productRoutePipe).toBeTruthy(); - }); - - function dataProvider() { - return [ - { - product: { sku: 'SKU' }, - category: { uniqueId: 'CAT' }, - expected: '/category/CAT/product/SKU', - }, - { product: { sku: 'SKU' }, category: undefined, expected: '/product/SKU' }, - { product: {}, category: undefined, expected: '/' }, - { product: undefined, category: undefined, expected: '/' }, - { - product: { sku: 'SKU', name: 'name' }, - category: { uniqueId: 'CAT' }, - expected: '/category/CAT/product/SKU/name', - }, - { product: { sku: 'SKU', name: 'name' }, category: undefined, expected: '/product/SKU/name' }, - { product: { sku: 'A' }, expected: '/product/A' }, - { product: { sku: 'A', name: '' }, expected: '/product/A' }, - { product: { sku: 'A', name: 'some example name' }, expected: '/product/A/some-example-name' }, - { product: { sku: 'A', name: 'name & speci@l char$' }, expected: '/product/A/name-speci-l-char' }, - ]; - } - - using(dataProvider, dataSlice => { - it(`should return ${dataSlice.expected} when supplying product '${JSON.stringify( - dataSlice.product - )}' and category '${JSON.stringify(dataSlice.category)}'`, () => { - expect(productRoutePipe.transform(dataSlice.product, dataSlice.category)).toEqual(dataSlice.expected); - }); - }); -}); diff --git a/src/app/core/pipes/product-route.pipe.ts b/src/app/core/pipes/product-route.pipe.ts deleted file mode 100644 index ba21992846..0000000000 --- a/src/app/core/pipes/product-route.pipe.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; - -import { Category } from 'ish-core/models/category/category.model'; -import { Product } from 'ish-core/models/product/product.model'; - -function generateProductSlug(product: Product) { - return product && product.name ? product.name.replace(/[^a-zA-Z0-9-]+/g, '-').replace(/-+$/g, '') : undefined; -} - -/** - * Generate a product detail route with optional category context. - * @param product The Product to genereate the route for - * @param category The optional Category that should be used as context for the product route - * @returns Product route string - */ - -export function generateProductRoute(product: Product, category?: Category): string { - if (!(product && product.sku)) { - return '/'; - } - let productRoute = '/product/' + product.sku; - const productSlug = generateProductSlug(product); - if (productSlug) { - productRoute += '/' + productSlug; - } - - if (category) { - productRoute = `/category/${category.uniqueId}${productRoute}`; - } else { - // TODO: add defaultCategory to route once this information is available with the products REST call - } - return productRoute; -} - -@Pipe({ name: 'ishProductRoute', pure: true }) -export class ProductRoutePipe implements PipeTransform { - transform(product: Product, category?: Category): string { - return generateProductRoute(product, category); - } -} diff --git a/src/app/core/routing/product/product-route.pipe.ts b/src/app/core/routing/product/product-route.pipe.ts new file mode 100644 index 0000000000..b9ab7e5cc5 --- /dev/null +++ b/src/app/core/routing/product/product-route.pipe.ts @@ -0,0 +1,12 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { CategoryView } from 'ish-core/models/category-view/category-view.model'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; +import { generateProductUrl } from 'ish-core/routing/product/product.route'; + +@Pipe({ name: 'ishProductRoute', pure: true }) +export class ProductRoutePipe implements PipeTransform { + transform(product: ProductView, category?: CategoryView): string { + return generateProductUrl(product, category); + } +} diff --git a/src/app/core/routing/product/product.route.spec.ts b/src/app/core/routing/product/product.route.spec.ts new file mode 100644 index 0000000000..8241cfe4de --- /dev/null +++ b/src/app/core/routing/product/product.route.spec.ts @@ -0,0 +1,342 @@ +import { TestBed } from '@angular/core/testing'; +import { Router, UrlMatchResult, UrlSegment } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { cold } from 'jest-marbles'; +import { RouteNavigation } from 'ngrx-router'; +import { of } from 'rxjs'; + +import { createCategoryView } from 'ish-core/models/category-view/category-view.model'; +import { Category } from 'ish-core/models/category/category.model'; +import { createProductView } from 'ish-core/models/product-view/product-view.model'; +import { VariationProduct } from 'ish-core/models/product/product-variation.model'; +import { Product } from 'ish-core/models/product/product.model'; +import { categoryTree } from 'ish-core/utils/dev/test-data-utils'; + +import { generateProductUrl, matchProductRoute, ofProductRoute } from './product.route'; + +describe('Product Route', () => { + const specials = { categoryPath: ['Specials'], uniqueId: 'Specials', name: 'Spezielles' } as Category; + const topSeller = { + categoryPath: ['Specials', 'Specials.TopSeller'], + uniqueId: 'Specials.TopSeller', + name: 'Angebote', + } as Category; + const limitedOffer = { + categoryPath: ['Specials', 'Specials.TopSeller', 'Specials.TopSeller.LimitedOffer'], + uniqueId: 'Specials.TopSeller.LimitedOffer', + name: 'Black Friday', + } as Category; + + expect.addSnapshotSerializer({ + test: val => val && val.consumed && val.posParams, + print: (val: UrlMatchResult, serialize) => + serialize( + Object.keys(val.posParams) + .map(key => ({ [key]: val.posParams[key].path })) + .reduce((acc, v) => ({ ...acc, ...v }), {}) + ), + }); + + let wrap: (url: string) => UrlSegment[]; + + beforeEach(() => { + TestBed.configureTestingModule({ imports: [RouterTestingModule] }); + const router: Router = TestBed.get(Router); + wrap = url => { + const primary = router.parseUrl(url).root.children.primary; + return primary ? primary.segments : []; + }; + }); + + describe('without anything', () => { + it('should be created', () => { + expect(generateProductUrl(undefined)).toMatchInlineSnapshot(`"/"`); + expect(generateProductUrl(undefined, undefined)).toMatchInlineSnapshot(`"/"`); + }); + + it('should not be a match for matcher', () => { + expect(matchProductRoute(wrap(generateProductUrl(undefined)))).toMatchInlineSnapshot(`undefined`); + }); + }); + + describe('without category', () => { + describe('without product name', () => { + const product = createProductView({ sku: 'A' } as Product, categoryTree()); + it('should create simple link when just sku is supplied', () => { + expect(generateProductUrl(product)).toMatchInlineSnapshot(`"/skuA"`); + }); + + it('should be a match for matcher', () => { + expect(matchProductRoute(wrap(generateProductUrl(product)))).toMatchInlineSnapshot(` + Object { + "sku": "A", + } + `); + }); + }); + + describe('with product name', () => { + const product = createProductView({ sku: 'A', name: 'some example name' } as Product, categoryTree()); + + it('should include slug when product has a name', () => { + expect(generateProductUrl(product)).toMatchInlineSnapshot(`"/some-example-name-skuA"`); + }); + + it('should include filtered slug when product has a name with special characters', () => { + const product2 = { ...product, name: 'name & speci@l char$' }; + expect(generateProductUrl(product2)).toMatchInlineSnapshot(`"/name-&-speci@l-char$-skuA"`); + }); + + it('should be a match for matcher', () => { + expect(matchProductRoute(wrap(generateProductUrl(product)))).toMatchInlineSnapshot(` + Object { + "sku": "A", + } + `); + }); + }); + + describe('variation product', () => { + const product = createProductView( + { + sku: 'A', + name: 'some example name', + type: 'VariationProduct', + variableVariationAttributes: [{ value: 'SSD' }, { value: 'Blue' }], + } as VariationProduct, + categoryTree() + ); + + it('should include attribute values in slug when product is a variation', () => { + expect(generateProductUrl(product)).toMatchInlineSnapshot(`"/some-example-name-SSD-Blue-skuA"`); + }); + }); + }); + + describe('with top level category', () => { + const categories = categoryTree([specials]); + const category = createCategoryView(categories, specials.uniqueId); + + describe('as context', () => { + describe('without product name', () => { + const product = createProductView({ sku: 'A' } as Product, categories); + + it('should be created', () => { + expect(generateProductUrl(product, category)).toMatchInlineSnapshot(`"/Spezielles/skuA-catSpecials"`); + }); + + it('should be a match for matcher', () => { + expect(matchProductRoute(wrap(generateProductUrl(product, category)))).toMatchInlineSnapshot(` + Object { + "categoryUniqueId": "Specials", + "sku": "A", + } + `); + }); + }); + + describe('with product name', () => { + const product = createProductView({ sku: 'A', name: 'Das neue Surface Pro 7' } as Product, categories); + + it('should be created', () => { + expect(generateProductUrl(product, category)).toMatchInlineSnapshot( + `"/Spezielles/Das-neue-Surface-Pro-7-skuA-catSpecials"` + ); + }); + + it('should be a match for matcher', () => { + expect(matchProductRoute(wrap(generateProductUrl(product, category)))).toMatchInlineSnapshot(` + Object { + "categoryUniqueId": "Specials", + "sku": "A", + } + `); + }); + }); + }); + + describe('as default category', () => { + describe('without product name', () => { + const product = createProductView({ sku: 'A', defaultCategoryId: specials.uniqueId } as Product, categories); + + it('should be created', () => { + expect(generateProductUrl(product)).toMatchInlineSnapshot(`"/Spezielles/skuA-catSpecials"`); + }); + + it('should be a match for matcher', () => { + expect(matchProductRoute(wrap(generateProductUrl(product)))).toMatchInlineSnapshot(` + Object { + "categoryUniqueId": "Specials", + "sku": "A", + } + `); + }); + }); + + describe('with product name', () => { + const product = createProductView( + { sku: 'A', name: 'Das neue Surface Pro 7', defaultCategoryId: specials.uniqueId } as Product, + categories + ); + + it('should be created', () => { + expect(generateProductUrl(product)).toMatchInlineSnapshot( + `"/Spezielles/Das-neue-Surface-Pro-7-skuA-catSpecials"` + ); + }); + + it('should be a match for matcher', () => { + expect(matchProductRoute(wrap(generateProductUrl(product)))).toMatchInlineSnapshot(` + Object { + "categoryUniqueId": "Specials", + "sku": "A", + } + `); + }); + }); + }); + }); + + describe('with deep category', () => { + const categories = categoryTree([specials, topSeller, limitedOffer]); + const category = createCategoryView(categories, limitedOffer.uniqueId); + + describe('as context', () => { + describe('without product name', () => { + const product = createProductView({ sku: 'A' } as Product, categories); + + it('should be created', () => { + expect(generateProductUrl(product, category)).toMatchInlineSnapshot( + `"/Spezielles/Angebote/Black-Friday/skuA-catSpecials.TopSeller.LimitedOffer"` + ); + }); + + it('should be a match for matcher', () => { + expect(matchProductRoute(wrap(generateProductUrl(product, category)))).toMatchInlineSnapshot(` + Object { + "categoryUniqueId": "Specials.TopSeller.LimitedOffer", + "sku": "A", + } + `); + }); + }); + + describe('with product name', () => { + const product = createProductView({ sku: 'A', name: 'Das neue Surface Pro 7' } as Product, categories); + + it('should be created', () => { + expect(generateProductUrl(product, category)).toMatchInlineSnapshot( + `"/Spezielles/Angebote/Black-Friday/Das-neue-Surface-Pro-7-skuA-catSpecials.TopSeller.LimitedOffer"` + ); + }); + + it('should be a match for matcher', () => { + expect(matchProductRoute(wrap(generateProductUrl(product, category)))).toMatchInlineSnapshot(` + Object { + "categoryUniqueId": "Specials.TopSeller.LimitedOffer", + "sku": "A", + } + `); + }); + }); + }); + + describe('as default category', () => { + describe('without product name', () => { + const product = createProductView( + { sku: 'A', defaultCategoryId: limitedOffer.uniqueId } as Product, + categories + ); + + it('should be created', () => { + expect(generateProductUrl(product)).toMatchInlineSnapshot( + `"/Spezielles/Angebote/Black-Friday/skuA-catSpecials.TopSeller.LimitedOffer"` + ); + }); + + it('should be a match for matcher', () => { + expect(matchProductRoute(wrap(generateProductUrl(product)))).toMatchInlineSnapshot(` + Object { + "categoryUniqueId": "Specials.TopSeller.LimitedOffer", + "sku": "A", + } + `); + }); + }); + + describe('with product name', () => { + const product = createProductView( + { sku: 'A', name: 'Das neue Surface Pro 7', defaultCategoryId: limitedOffer.uniqueId } as Product, + categories + ); + + it('should be created', () => { + expect(generateProductUrl(product)).toMatchInlineSnapshot( + `"/Spezielles/Angebote/Black-Friday/Das-neue-Surface-Pro-7-skuA-catSpecials.TopSeller.LimitedOffer"` + ); + }); + + it('should be a match for matcher', () => { + expect(matchProductRoute(wrap(generateProductUrl(product)))).toMatchInlineSnapshot(` + Object { + "categoryUniqueId": "Specials.TopSeller.LimitedOffer", + "sku": "A", + } + `); + }); + }); + }); + }); + + describe('compatibility', () => { + it.each(['/product/123', '/product/123/slug', '/category/123/product/123', '/category/123/product/123/slug'])( + 'should detect %p as route', + url => { + expect(matchProductRoute(wrap(url))).toHaveProperty('consumed'); + } + ); + + it('should not detect category route without product after it', () => { + expect(matchProductRoute(wrap('/category/123'))).toBeUndefined(); + }); + }); + + describe('additional URL params', () => { + it('should ignore additional URL params when supplied', () => { + const category = createCategoryView(categoryTree([specials, topSeller, limitedOffer]), limitedOffer.uniqueId); + const product = createProductView({ sku: 'A', name: 'Das neue Surface Pro 7' } as Product, categoryTree()); + + expect(matchProductRoute(wrap(generateProductUrl(product, category) + ';lang=de_DE;redirect=1'))) + .toMatchInlineSnapshot(` + Object { + "categoryUniqueId": "Specials.TopSeller.LimitedOffer", + "sku": "A", + } + `); + }); + }); + + describe('ofProductRoute', () => { + it('should detect product route when sku is a param', () => { + const stream$ = of(new RouteNavigation({ path: 'any', params: { sku: '123' } })); + + expect(stream$.pipe(ofProductRoute())).toBeObservable( + cold('(a|)', { + a: new RouteNavigation({ + params: { + sku: '123', + }, + path: 'any', + url: '/any', + }), + }) + ); + }); + + it('should not detect product route when sku is missing', () => { + const stream$ = of(new RouteNavigation({ path: 'any' })); + + expect(stream$.pipe(ofProductRoute())).toBeObservable(cold('|')); + }); + }); +}); diff --git a/src/app/core/routing/product/product.route.ts b/src/app/core/routing/product/product.route.ts new file mode 100644 index 0000000000..f7f2102586 --- /dev/null +++ b/src/app/core/routing/product/product.route.ts @@ -0,0 +1,93 @@ +import { UrlMatchResult, UrlSegment } from '@angular/router'; +import { RouteNavigation, ofRoute } from 'ngrx-router'; +import { MonoTypeOperatorFunction } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { CategoryView } from 'ish-core/models/category-view/category-view.model'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; +import { ProductHelper } from 'ish-core/models/product/product.model'; + +function generateProductSlug(product: ProductView) { + if (!product || !product.name) { + return; + } + + let slug = product.name.replace(/ /g, '-').replace(/-+$/g, ''); + + if (ProductHelper.isVariationProduct(product)) { + slug += '-'; + slug += product.variableVariationAttributes + .map(att => att.value) + .filter(val => typeof val === 'string' || typeof val === 'boolean' || typeof val === 'number') + .join('-'); + } + + return slug; +} + +const productRouteFormat = new RegExp('^/(.*)?sku(.*?)(-cat(.*))?$'); + +export function matchProductRoute(segments: UrlSegment[]): UrlMatchResult { + // compatibility to old routes + const isSimpleProduct = segments && segments.length > 0 && segments[0].path === 'product'; + const isContextProduct = + segments && segments.length > 2 && segments[0].path === 'category' && segments[2].path === 'product'; + if (isSimpleProduct || isContextProduct) { + return { consumed: [] }; + } + + const url = '/' + segments.map(s => s.path).join('/'); + if (productRouteFormat.test(url)) { + const match = productRouteFormat.exec(url); + const posParams: { [id: string]: UrlSegment } = {}; + if (match[4]) { + posParams.categoryUniqueId = new UrlSegment(match[4], {}); + } + if (match[2]) { + posParams.sku = new UrlSegment(match[2], {}); + } + return { + consumed: [], + posParams, + }; + } + return; +} + +export function generateProductUrl(product: ProductView, category?: CategoryView): string { + const contextCategory = category || (product && product.defaultCategory()); + + if (!(product && product.sku)) { + return '/'; + } + + let route = '/'; + + if (contextCategory) { + route += contextCategory + .pathCategories() + .map(cat => cat.name.replace(/ /g, '-')) + .join('/'); + route += '/'; + } + + if (product.name) { + route += `${generateProductSlug(product)}-`; + } + + route += `sku${product.sku}`; + + if (contextCategory) { + route += `-cat${contextCategory.uniqueId}`; + } + + return route; +} + +export function ofProductRoute(): MonoTypeOperatorFunction { + return source$ => + source$.pipe( + ofRoute(), + filter(action => action.payload.params && action.payload.params.sku) + ); +} diff --git a/src/app/core/store/shopping/products/products.effects.spec.ts b/src/app/core/store/shopping/products/products.effects.spec.ts index dd9619be15..b55e7cbb20 100644 --- a/src/app/core/store/shopping/products/products.effects.spec.ts +++ b/src/app/core/store/shopping/products/products.effects.spec.ts @@ -347,11 +347,13 @@ describe('Products Effects', () => { }); describe('redirectIfErrorInProducts$', () => { - it('should redirect if triggered on product detail page', fakeAsync(() => { - when(router.url).thenReturn('/category/A/product/SKU'); - - const action = new fromActions.LoadProductFail({ sku: 'SKU', error: { status: 404 } as HttpError }); + beforeEach(() => { + store$.dispatch(new fromActions.LoadProductFail({ sku: 'SKU', error: { status: 404 } as HttpError })); + store$.dispatch(new fromActions.SelectProduct({ sku: 'SKU' })); + }); + it('should redirect if triggered on product detail page', fakeAsync(() => { + const action = new RouteNavigation({ path: 'pr', params: { sku: 'SKU' } }); actions$ = of(action); effects.redirectIfErrorInProducts$.subscribe(noop, fail, noop); @@ -362,10 +364,7 @@ describe('Products Effects', () => { })); it('should not redirect if triggered on page other than product detail page', done => { - when(router.url).thenReturn('/search/term'); - - const action = new fromActions.LoadProductFail({ sku: 'SKU', error: { status: 404 } as HttpError }); - + const action = new RouteNavigation({ path: 'any' }); actions$ = of(action); effects.redirectIfErrorInProducts$.subscribe(fail, fail, done); diff --git a/src/app/core/store/shopping/products/products.effects.ts b/src/app/core/store/shopping/products/products.effects.ts index 2ff2c824d9..113dc6b650 100644 --- a/src/app/core/store/shopping/products/products.effects.ts +++ b/src/app/core/store/shopping/products/products.effects.ts @@ -1,5 +1,4 @@ import { Injectable } from '@angular/core'; -import { Router } from '@angular/router'; import { Actions, Effect, ofType } from '@ngrx/effects'; import { Dictionary } from '@ngrx/entity'; import { Store, select } from '@ngrx/store'; @@ -8,11 +7,14 @@ import { concatMap, distinct, distinctUntilChanged, + distinctUntilKeyChanged, filter, groupBy, map, mergeMap, switchMap, + switchMapTo, + take, takeUntil, tap, throttleTime, @@ -23,6 +25,7 @@ import { ProductListingMapper } from 'ish-core/models/product-listing/product-li import { VariationProductMaster } from 'ish-core/models/product/product-variation-master.model'; import { VariationProduct } from 'ish-core/models/product/product-variation.model'; import { Product, ProductCompletenessLevel, ProductHelper } from 'ish-core/models/product/product.model'; +import { ofProductRoute } from 'ish-core/routing/product/product.route'; import { ProductsService } from 'ish-core/services/products/products.service'; import { LoadCategory } from 'ish-core/store/shopping/categories'; import { SetProductListingPages } from 'ish-core/store/shopping/product-listing'; @@ -32,6 +35,7 @@ import { mapToPayload, mapToPayloadProperty, mapToProperty, + whenFalsy, whenTruthy, } from 'ish-core/utils/operators'; @@ -44,7 +48,6 @@ export class ProductsEffects { private actions$: Actions, private store: Store<{}>, private productsService: ProductsService, - private router: Router, private httpStatusCodeService: HttpStatusCodeService, private productListingMapper: ProductListingMapper ) {} @@ -214,11 +217,14 @@ export class ProductsEffects { @Effect() loadDefaultCategoryContextForProduct$ = this.actions$.pipe( - ofRoute(/^product/), + ofProductRoute(), + mapToParam('categoryUniqueId'), + whenFalsy(), switchMap(() => this.store.pipe( select(productsSelectors.getSelectedProduct), whenTruthy(), + filter(p => !ProductHelper.isFailedLoading(p)), filter(product => !product.defaultCategory()), mapToProperty('defaultCategoryId'), whenTruthy(), @@ -264,8 +270,16 @@ export class ProductsEffects { @Effect({ dispatch: false }) redirectIfErrorInProducts$ = this.actions$.pipe( - ofType(productsActions.ProductsActionTypes.LoadProductFail), - filter(() => this.router.url.includes('/product/')), + ofProductRoute(), + switchMapTo( + this.store.pipe( + select(productsSelectors.getSelectedProduct), + whenTruthy(), + distinctUntilKeyChanged('sku'), + filter(ProductHelper.isFailedLoading), + take(1) + ) + ), tap(() => this.httpStatusCodeService.setStatusAndRedirect(404)) ); diff --git a/src/app/core/store/shopping/products/products.selectors.ts b/src/app/core/store/shopping/products/products.selectors.ts index 86acc52090..9ba4831b53 100644 --- a/src/app/core/store/shopping/products/products.selectors.ts +++ b/src/app/core/store/shopping/products/products.selectors.ts @@ -109,9 +109,7 @@ export const getProducts = createSelector( export const getSelectedProduct = createSelector( state => state, getSelectedProductId, - getFailed, - (state, sku, failed): ProductView | VariationProductView | VariationProductMasterView => - failed.includes(sku) ? undefined : getProduct(state, { sku }) + (state, sku): ProductView | VariationProductView | VariationProductMasterView => getProduct(state, { sku }) ); export const getProductVariationOptions = createSelector( diff --git a/src/app/core/store/shopping/recently/recently.effects.spec.ts b/src/app/core/store/shopping/recently/recently.effects.spec.ts index de040566fb..0d44242a99 100644 --- a/src/app/core/store/shopping/recently/recently.effects.spec.ts +++ b/src/app/core/store/shopping/recently/recently.effects.spec.ts @@ -5,10 +5,11 @@ import { cold, hot } from 'jest-marbles'; import { Observable } from 'rxjs'; import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; +import { HttpError } from 'ish-core/models/http-error/http-error.model'; import { Product } from 'ish-core/models/product/product.model'; import { ApplyConfiguration } from 'ish-core/store/configuration'; import { configurationReducer } from 'ish-core/store/configuration/configuration.reducer'; -import { LoadProductSuccess, SelectProduct } from 'ish-core/store/shopping/products'; +import { LoadProductFail, LoadProductSuccess, SelectProduct } from 'ish-core/store/shopping/products'; import { shoppingReducers } from 'ish-core/store/shopping/shopping-store.module'; import { ngrxTesting } from 'ish-core/utils/dev/ngrx-testing'; @@ -54,6 +55,13 @@ describe('Recently Effects', () => { expect(effects.viewedProduct$).toBeObservable(cold('a', { a: new AddToRecently({ sku: 'A' }) })); }); + it('should not fire when product failed loading', () => { + store$.dispatch(new LoadProductFail({ error: {} as HttpError, sku: 'A' })); + store$.dispatch(new SelectProduct({ sku: 'A' })); + + expect(effects.viewedProduct$).toBeObservable(cold('------')); + }); + it('should not fire when product is deselected', () => { store$.dispatch(new SelectProduct({ sku: undefined })); diff --git a/src/app/core/store/shopping/recently/recently.effects.ts b/src/app/core/store/shopping/recently/recently.effects.ts index 4fcd8f412a..1385124cc5 100644 --- a/src/app/core/store/shopping/recently/recently.effects.ts +++ b/src/app/core/store/shopping/recently/recently.effects.ts @@ -18,6 +18,7 @@ export class RecentlyEffects { viewedProduct$ = this.store.pipe( select(getSelectedProduct), whenTruthy(), + filter(p => !ProductHelper.isFailedLoading(p)), distinctUntilKeyChanged('sku'), filter( product => diff --git a/src/app/extensions/seo/store/seo/seo.effects.ts b/src/app/extensions/seo/store/seo/seo.effects.ts index 9f04d63c56..47e37e714e 100644 --- a/src/app/extensions/seo/store/seo/seo.effects.ts +++ b/src/app/extensions/seo/store/seo/seo.effects.ts @@ -6,11 +6,11 @@ import { REQUEST } from '@nguniversal/express-engine/tokens'; import { MetaService } from '@ngx-meta/core'; import { TranslateService } from '@ngx-translate/core'; import { mapToParam, ofRoute } from 'ngrx-router'; -import { debounce, distinctUntilKeyChanged, first, map, switchMap, tap } from 'rxjs/operators'; +import { debounce, distinctUntilKeyChanged, filter, first, map, switchMap, tap } from 'rxjs/operators'; import { ProductHelper } from 'ish-core/models/product/product.helper'; import { SeoAttributes } from 'ish-core/models/seo-attribute/seo-attribute.model'; -import { generateProductRoute } from 'ish-core/pipes/product-route.pipe'; +import { generateProductUrl, ofProductRoute } from 'ish-core/routing/product/product.route'; import { getSelectedContentPage } from 'ish-core/store/content/pages'; import { CategoriesActionTypes } from 'ish-core/store/shopping/categories'; import { getSelectedCategory } from 'ish-core/store/shopping/categories/categories.selectors'; @@ -97,17 +97,18 @@ export class SeoEffects { @Effect() seoProduct$ = this.actions$.pipe( - ofRoute(['product/:sku/**', 'category/:categoryUniqueId/product/:sku/**']), + ofProductRoute(), switchMap(() => this.store.pipe( select(getSelectedProduct), whenTruthy(), + filter(p => !ProductHelper.isFailedLoading(p)), map(p => (ProductHelper.isVariationProduct(p) && p.productMaster()) || p), distinctUntilKeyChanged('sku'), map(p => { const productImage = ProductHelper.getPrimaryImage(p, 'L'); const seoAttributes = { - canonical: generateProductRoute(p, p.defaultCategory()), + canonical: generateProductUrl(p), 'og:image': productImage && productImage.effectiveUrl, }; return p.seoAttributes ? { ...seoAttributes, ...p.seoAttributes } : seoAttributes; diff --git a/src/app/pages/app-last-routing.module.ts b/src/app/pages/app-last-routing.module.ts index cf332bb175..a94468403e 100644 --- a/src/app/pages/app-last-routing.module.ts +++ b/src/app/pages/app-last-routing.module.ts @@ -1,7 +1,15 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; -const routes: Routes = [{ path: '**', redirectTo: '/error' }]; +import { matchProductRoute } from 'ish-core/routing/product/product.route'; + +const routes: Routes = [ + { + matcher: matchProductRoute, + loadChildren: () => import('./product/product-page.module').then(m => m.ProductPageModule), + }, + { path: '**', redirectTo: '/error' }, +]; @NgModule({ imports: [RouterModule.forChild(routes)], diff --git a/src/app/pages/app-routing.module.ts b/src/app/pages/app-routing.module.ts index 61739eb14d..19d1a24184 100644 --- a/src/app/pages/app-routing.module.ts +++ b/src/app/pages/app-routing.module.ts @@ -28,10 +28,6 @@ const routes: Routes = [ }, }, }, - { - path: 'product', - loadChildren: () => import('./product/product-page.module').then(m => m.ProductPageModule), - }, { path: 'category', loadChildren: () => import('./category/category-page.module').then(m => m.CategoryPageModule), diff --git a/src/app/pages/compare/product-compare-list/product-compare-list.component.spec.ts b/src/app/pages/compare/product-compare-list/product-compare-list.component.spec.ts index 45a360f44a..a03d8832cd 100644 --- a/src/app/pages/compare/product-compare-list/product-compare-list.component.spec.ts +++ b/src/app/pages/compare/product-compare-list/product-compare-list.component.spec.ts @@ -7,7 +7,7 @@ import { MockComponent, MockPipe } from 'ng-mocks'; import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { AttributeToStringPipe } from 'ish-core/models/attribute/attribute.pipe'; import { Product } from 'ish-core/models/product/product.model'; -import { ProductRoutePipe } from 'ish-core/pipes/product-route.pipe'; +import { ProductRoutePipe } from 'ish-core/routing/product/product-route.pipe'; import { configurationReducer } from 'ish-core/store/configuration/configuration.reducer'; import { ngrxTesting } from 'ish-core/utils/dev/ngrx-testing'; import { ProductAddToBasketComponent } from 'ish-shared/components/product/product-add-to-basket/product-add-to-basket.component'; diff --git a/src/app/pages/product/product-page.component.spec.ts b/src/app/pages/product/product-page.component.spec.ts index 621fcc9f5c..cf50a4ae0c 100644 --- a/src/app/pages/product/product-page.component.spec.ts +++ b/src/app/pages/product/product-page.component.spec.ts @@ -6,6 +6,7 @@ import { RouterTestingModule } from '@angular/router/testing'; import { combineReducers } from '@ngrx/store'; import { cold } from 'jest-marbles'; import { MockComponent } from 'ng-mocks'; +import { noop } from 'rxjs'; import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { VariationSelection } from 'ish-core/models/product-variation/variation-selection.model'; @@ -14,7 +15,7 @@ import { ProductRetailSet } from 'ish-core/models/product/product-retail-set.mod import { VariationProductMaster } from 'ish-core/models/product/product-variation-master.model'; import { VariationProduct } from 'ish-core/models/product/product-variation.model'; import { Product, ProductCompletenessLevel } from 'ish-core/models/product/product.model'; -import { ProductRoutePipe } from 'ish-core/pipes/product-route.pipe'; +import { ProductRoutePipe } from 'ish-core/routing/product/product-route.pipe'; import { ApplyConfiguration } from 'ish-core/store/configuration'; import { coreReducers } from 'ish-core/store/core-store.module'; import { LoadProductSuccess, LoadProductVariationsSuccess, SelectProduct } from 'ish-core/store/shopping/products'; @@ -47,7 +48,7 @@ describe('Product Page Component', () => { TestBed.configureTestingModule({ imports: [ FeatureToggleModule, - RouterTestingModule.withRoutes([{ path: 'product/:sku', component: DummyComponent }]), + RouterTestingModule.withRoutes([{ path: '**', component: DummyComponent }]), ngrxTesting({ reducers: { ...coreReducers, @@ -144,6 +145,7 @@ describe('Product Page Component', () => { { name: 'Attr 1', type: 'VariationAttribute', value: 'A', variationAttributeId: 'a1' }, { name: 'Attr 2', type: 'VariationAttribute', value: 'D', variationAttributeId: 'a2' }, ], + defaultCategory: noop, }, ], } as VariationProductView; @@ -156,7 +158,7 @@ describe('Product Page Component', () => { component.variationSelected(selection, product); tick(500); - expect(location.path()).toEqual('/product/333'); + expect(location.path()).toMatchInlineSnapshot(`"/sku333"`); })); describe('redirecting to default variation', () => { @@ -190,7 +192,7 @@ describe('Product Page Component', () => { fixture.detectChanges(); tick(500); - expect(location.path()).toMatchInlineSnapshot(`"/product/222"`); + expect(location.path()).toMatchInlineSnapshot(`"/sku222"`); })); it('should not redirect to default variation for master product if advanced variation handling is activated', fakeAsync(() => { diff --git a/src/app/pages/product/product-page.component.ts b/src/app/pages/product/product-page.component.ts index f05f629e8c..68308a54fe 100644 --- a/src/app/pages/product/product-page.component.ts +++ b/src/app/pages/product/product-page.component.ts @@ -21,7 +21,7 @@ import { ProductPrices, SkuQuantityType, } from 'ish-core/models/product/product.model'; -import { ProductRoutePipe } from 'ish-core/pipes/product-route.pipe'; +import { generateProductUrl } from 'ish-core/routing/product/product.route'; import { whenTruthy } from 'ish-core/utils/operators'; @Component({ @@ -51,7 +51,6 @@ export class ProductPageComponent implements OnInit, OnDestroy { private appFacade: AppFacade, private shoppingFacade: ShoppingFacade, private router: Router, - private prodRoutePipe: ProductRoutePipe, private featureToggleService: FeatureToggleService, private appRef: ApplicationRef, private ngZone: NgZone @@ -138,7 +137,7 @@ export class ProductPageComponent implements OnInit, OnDestroy { } redirectToVariation(variation: VariationProductView, replaceUrl = false) { - const route = variation && this.prodRoutePipe.transform(variation); + const route = variation && generateProductUrl(variation); if (route) { this.appRef.isStable .pipe( diff --git a/src/app/pages/product/product-page.module.ts b/src/app/pages/product/product-page.module.ts index 18ed9471b8..81f1aed23c 100644 --- a/src/app/pages/product/product-page.module.ts +++ b/src/app/pages/product/product-page.module.ts @@ -16,7 +16,8 @@ import { RetailSetPartsComponent } from './retail-set-parts/retail-set-parts.com const productPageRoutes: Routes = [ { - path: ':sku', + // compatibility to old routes + path: 'product/:sku', children: [ { path: '**', @@ -24,6 +25,17 @@ const productPageRoutes: Routes = [ }, ], }, + { + // compatibility to old routes + path: 'category/:categoryUniqueId/product/:sku', + children: [ + { + path: '**', + component: ProductPageComponent, + }, + ], + }, + { path: '**', component: ProductPageComponent }, ]; @NgModule({ diff --git a/src/app/shared/components/basket/basket-items-summary/basket-items-summary.component.spec.ts b/src/app/shared/components/basket/basket-items-summary/basket-items-summary.component.spec.ts index 3cf49955ff..bf2b538b8f 100644 --- a/src/app/shared/components/basket/basket-items-summary/basket-items-summary.component.spec.ts +++ b/src/app/shared/components/basket/basket-items-summary/basket-items-summary.component.spec.ts @@ -5,7 +5,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { MockComponent, MockPipe } from 'ng-mocks'; import { PricePipe } from 'ish-core/models/price/price.pipe'; -import { ProductRoutePipe } from 'ish-core/pipes/product-route.pipe'; +import { ProductRoutePipe } from 'ish-core/routing/product/product-route.pipe'; import { BasketMockData } from 'ish-core/utils/dev/basket-mock-data'; import { BasketPromotionComponent } from 'ish-shared/components/basket/basket-promotion/basket-promotion.component'; import { PromotionDetailsComponent } from 'ish-shared/components/promotion/promotion-details/promotion-details.component'; diff --git a/src/app/shared/components/basket/basket-validation-items/basket-validation-items.component.spec.ts b/src/app/shared/components/basket/basket-validation-items/basket-validation-items.component.spec.ts index a4e0da359b..ccc9b206d3 100644 --- a/src/app/shared/components/basket/basket-validation-items/basket-validation-items.component.spec.ts +++ b/src/app/shared/components/basket/basket-validation-items/basket-validation-items.component.spec.ts @@ -5,7 +5,7 @@ import { MockComponent, MockPipe } from 'ng-mocks'; import { spy, verify } from 'ts-mockito'; import { PricePipe } from 'ish-core/models/price/price.pipe'; -import { ProductRoutePipe } from 'ish-core/pipes/product-route.pipe'; +import { ProductRoutePipe } from 'ish-core/routing/product/product-route.pipe'; import { BasketMockData } from 'ish-core/utils/dev/basket-mock-data'; import { ProductInventoryComponent } from 'ish-shared/components/product/product-inventory/product-inventory.component'; import { ProductImageComponent } from 'ish-shell/header/product-image/product-image.component'; diff --git a/src/app/shared/components/basket/basket-validation-products/basket-validation-products.component.spec.ts b/src/app/shared/components/basket/basket-validation-products/basket-validation-products.component.spec.ts index 437e5a4b54..e552de1774 100644 --- a/src/app/shared/components/basket/basket-validation-products/basket-validation-products.component.spec.ts +++ b/src/app/shared/components/basket/basket-validation-products/basket-validation-products.component.spec.ts @@ -5,7 +5,7 @@ import { MockComponent, MockPipe } from 'ng-mocks'; import { PricePipe } from 'ish-core/models/price/price.pipe'; import { Product } from 'ish-core/models/product/product.model'; -import { ProductRoutePipe } from 'ish-core/pipes/product-route.pipe'; +import { ProductRoutePipe } from 'ish-core/routing/product/product-route.pipe'; import { ProductInventoryComponent } from 'ish-shared/components/product/product-inventory/product-inventory.component'; import { ProductImageComponent } from 'ish-shell/header/product-image/product-image.component'; diff --git a/src/app/shared/components/basket/line-item-list/line-item-list.component.spec.ts b/src/app/shared/components/basket/line-item-list/line-item-list.component.spec.ts index a5493991a5..ec24d55d42 100644 --- a/src/app/shared/components/basket/line-item-list/line-item-list.component.spec.ts +++ b/src/app/shared/components/basket/line-item-list/line-item-list.component.spec.ts @@ -9,7 +9,7 @@ import { anything, spy, verify } from 'ts-mockito'; import { LineItemView } from 'ish-core/models/line-item/line-item.model'; import { Price } from 'ish-core/models/price/price.model'; import { PricePipe } from 'ish-core/models/price/price.pipe'; -import { ProductRoutePipe } from 'ish-core/pipes/product-route.pipe'; +import { ProductRoutePipe } from 'ish-core/routing/product/product-route.pipe'; import { findAllIshElements } from 'ish-core/utils/dev/html-query-utils'; import { BasketPromotionComponent } from 'ish-shared/components/basket/basket-promotion/basket-promotion.component'; import { LineItemDescriptionComponent } from 'ish-shared/components/basket/line-item-description/line-item-description.component'; diff --git a/src/app/shared/components/product/product-row/product-row.component.spec.ts b/src/app/shared/components/product/product-row/product-row.component.spec.ts index ee20515ac0..c3420c3406 100644 --- a/src/app/shared/components/product/product-row/product-row.component.spec.ts +++ b/src/app/shared/components/product/product-row/product-row.component.spec.ts @@ -6,7 +6,7 @@ import { MockComponent, MockPipe } from 'ng-mocks'; import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { ProductView } from 'ish-core/models/product-view/product-view.model'; -import { ProductRoutePipe } from 'ish-core/pipes/product-route.pipe'; +import { ProductRoutePipe } from 'ish-core/routing/product/product-route.pipe'; import { configurationReducer } from 'ish-core/store/configuration/configuration.reducer'; import { findAllIshElements } from 'ish-core/utils/dev/html-query-utils'; import { ngrxTesting } from 'ish-core/utils/dev/ngrx-testing'; diff --git a/src/app/shared/components/product/product-row/product-row.component.ts b/src/app/shared/components/product/product-row/product-row.component.ts index b6c6ea2389..125122f28a 100644 --- a/src/app/shared/components/product/product-row/product-row.component.ts +++ b/src/app/shared/components/product/product-row/product-row.component.ts @@ -3,7 +3,7 @@ import { FormControl, FormGroup } from '@angular/forms'; import { Subject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; -import { Category } from 'ish-core/models/category/category.model'; +import { CategoryView } from 'ish-core/models/category-view/category-view.model'; import { VariationOptionGroup } from 'ish-core/models/product-variation/variation-option-group.model'; import { VariationSelection } from 'ish-core/models/product-variation/variation-selection.model'; import { @@ -42,7 +42,7 @@ export class ProductRowComponent implements OnInit, OnDestroy { @Input() quantity: number; @Output() quantityChange = new EventEmitter(); @Input() variationOptions: VariationOptionGroup[]; - @Input() category?: Category; + @Input() category?: CategoryView; @Input() isInCompareList: boolean; @Output() compareToggle = new EventEmitter(); @Output() productToBasket = new EventEmitter(); diff --git a/src/app/shared/components/product/product-tile/product-tile.component.spec.ts b/src/app/shared/components/product/product-tile/product-tile.component.spec.ts index 53c8bcc111..18e47c81d2 100644 --- a/src/app/shared/components/product/product-tile/product-tile.component.spec.ts +++ b/src/app/shared/components/product/product-tile/product-tile.component.spec.ts @@ -5,7 +5,7 @@ import { MockComponent, MockPipe } from 'ng-mocks'; import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { ProductView } from 'ish-core/models/product-view/product-view.model'; -import { ProductRoutePipe } from 'ish-core/pipes/product-route.pipe'; +import { ProductRoutePipe } from 'ish-core/routing/product/product-route.pipe'; import { configurationReducer } from 'ish-core/store/configuration/configuration.reducer'; import { findAllIshElements } from 'ish-core/utils/dev/html-query-utils'; import { ngrxTesting } from 'ish-core/utils/dev/ngrx-testing'; diff --git a/src/app/shared/components/product/product-tile/product-tile.component.ts b/src/app/shared/components/product/product-tile/product-tile.component.ts index ba0ba0b086..5aaed1c7eb 100644 --- a/src/app/shared/components/product/product-tile/product-tile.component.ts +++ b/src/app/shared/components/product/product-tile/product-tile.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; -import { Category } from 'ish-core/models/category/category.model'; +import { CategoryView } from 'ish-core/models/category-view/category-view.model'; import { VariationOptionGroup } from 'ish-core/models/product-variation/variation-option-group.model'; import { VariationSelection } from 'ish-core/models/product-variation/variation-selection.model'; import { @@ -31,7 +31,7 @@ export class ProductTileComponent { @Input() product: ProductView | VariationProductView | VariationProductMasterView; @Input() quantity: number; @Input() variationOptions: VariationOptionGroup[]; - @Input() category: Category; + @Input() category: CategoryView; @Input() isInCompareList: boolean; @Output() compareToggle = new EventEmitter(); @Output() productToBasket = new EventEmitter(); diff --git a/src/app/shell/header/mini-basket/mini-basket.component.spec.ts b/src/app/shell/header/mini-basket/mini-basket.component.spec.ts index 7417e607e2..e7815c130b 100644 --- a/src/app/shell/header/mini-basket/mini-basket.component.spec.ts +++ b/src/app/shell/header/mini-basket/mini-basket.component.spec.ts @@ -8,7 +8,7 @@ import { instance, mock, when } from 'ts-mockito'; import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; import { PricePipe } from 'ish-core/models/price/price.pipe'; -import { ProductRoutePipe } from 'ish-core/pipes/product-route.pipe'; +import { ProductRoutePipe } from 'ish-core/routing/product/product-route.pipe'; import { BasketMockData } from 'ish-core/utils/dev/basket-mock-data'; import { ProductImageComponent } from 'ish-shell/header/product-image/product-image.component'; diff --git a/tslint.json b/tslint.json index ae19b41bc0..0278a69f91 100644 --- a/tslint.json +++ b/tslint.json @@ -497,6 +497,9 @@ // core "^.*/src/app/core/[a-z][a-z0-9-]+\\.module\\.ts", "^.*/src/app/core/configurations/.*", + // custom routes + "^.*/src/app/core/routing/([a-z0-9-]+)/\\1\\.route\\.ts", + "^.*/src/app/core/routing/([a-z0-9-]+)/\\1\\-route\\.pipe\\.ts", // extra artifacts "^.*/src/app/(core|extensions/[a-z][a-z0-9-]+)/(service)s/([a-z][a-z0-9-]+)/\\3(\\-[a-z0-9-]+|)\\.\\2[a-z0-9-\\.]*\\.ts", "^.*/src/app/(core|extensions/[a-z][a-z0-9-]+)/(interceptor|guard|directive|pipe)s/[a-z][a-z0-9-]+.\\2\\.ts", @@ -563,6 +566,23 @@ "name": "^(CMS[A-Z].*Page)Component$", "file": ".*//\\.component\\.ts$" }, + // custom routing + { + "name": "^([A-Z].*)RoutePipe$", + "file": ".*/core/routing//-route\\.pipe\\.ts$" + }, + { + "name": "^generate([A-Z].*)Url$", + "file": ".*/core/routing//.route\\.ts$" + }, + { + "name": "^match([A-Z].*)Route$", + "file": ".*/core/routing//.route\\.ts$" + }, + { + "name": "^of([A-Z].*)Route$", + "file": ".*/core/routing//.route\\.ts$" + }, // angular components { "name": "^([A-Z].*)PageComponent$",