From d99c98bf604028a0853f7f4a3c0c85f07d28f7c1 Mon Sep 17 00:00:00 2001 From: Danilo Hoffmann Date: Wed, 12 Feb 2020 14:19:59 +0100 Subject: [PATCH] feat: SEO friendly localized URLs for product detail pages (#110) - moved ProductRoutePipe to core/routing/product - match incoming URLs for products with Angular UrlMatcher and RegExp pattern - improved typing of ProductRoutePipe BREAKING CHANGE: product URLs no longer match static pattern .*/product/ but can instead be localized and customized for any pattern --- src/app/core/pipes.module.ts | 2 +- src/app/core/pipes/product-route.pipe.spec.ts | 50 ---- src/app/core/pipes/product-route.pipe.ts | 40 --- .../routing/product/product-route.pipe.ts | 12 + .../routing/product/product.route.spec.ts | 263 ++++++++++++++++++ src/app/core/routing/product/product.route.ts | 59 ++++ .../shopping/products/products.effects.ts | 6 +- .../extensions/seo/store/seo/seo.effects.ts | 14 +- src/app/pages/app-last-routing.module.ts | 12 +- src/app/pages/app-routing.module.ts | 5 - .../product-compare-list.component.spec.ts | 2 +- .../product/product-page.component.spec.ts | 10 +- .../pages/product/product-page.component.ts | 5 +- src/app/pages/product/product-page.module.ts | 12 +- .../basket-items-summary.component.spec.ts | 2 +- .../basket-validation-items.component.spec.ts | 2 +- ...sket-validation-products.component.spec.ts | 2 +- .../line-item-list.component.spec.ts | 2 +- .../product-row/product-row.component.spec.ts | 2 +- .../product-row/product-row.component.ts | 4 +- .../product-tile.component.spec.ts | 2 +- .../product-tile/product-tile.component.ts | 4 +- .../mini-basket/mini-basket.component.spec.ts | 2 +- tslint.json | 20 ++ 24 files changed, 399 insertions(+), 135 deletions(-) delete mode 100644 src/app/core/pipes/product-route.pipe.spec.ts delete mode 100644 src/app/core/pipes/product-route.pipe.ts create mode 100644 src/app/core/routing/product/product-route.pipe.ts create mode 100644 src/app/core/routing/product/product.route.spec.ts create mode 100644 src/app/core/routing/product/product.route.ts 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..7ae6a98d05 --- /dev/null +++ b/src/app/core/routing/product/product.route.spec.ts @@ -0,0 +1,263 @@ +import { UrlMatchResult, UrlSegment } from '@angular/router'; + +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 { Product } from 'ish-core/models/product/product.model'; +import { categoryTree } from 'ish-core/utils/dev/test-data-utils'; + +import { generateProductUrl, matchProductRoute } 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 }), {}) + ), + }); + // tslint:disable-next-line: no-suspicious-variable-init-in-tests + const wrap = generated => + generated + .split('/') + .filter(x => x) + .map(path => new UrlSegment(path, {})); + + 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('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", + } + `); + }); + }); + }); + }); +}); 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..ec3e749158 --- /dev/null +++ b/src/app/core/routing/product/product.route.ts @@ -0,0 +1,59 @@ +import { UrlMatchResult, UrlSegment } from '@angular/router'; + +import { CategoryView } from 'ish-core/models/category-view/category-view.model'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; + +function generateProductSlug(product: ProductView) { + return product && product.name ? product.name.replace(/[^a-zA-Z0-9-]+/g, '-').replace(/-+$/g, '') : undefined; +} + +export const productRouteFormat = new RegExp('^/(.*)?sku(.*?)(-cat(.*))?$'); + +export function matchProductRoute(segments: UrlSegment[]): UrlMatchResult { + const url = '/' + segments.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; +} diff --git a/src/app/core/store/shopping/products/products.effects.ts b/src/app/core/store/shopping/products/products.effects.ts index 2ff2c824d9..cf08ac2383 100644 --- a/src/app/core/store/shopping/products/products.effects.ts +++ b/src/app/core/store/shopping/products/products.effects.ts @@ -23,6 +23,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 { productRouteFormat } 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 +33,7 @@ import { mapToPayload, mapToPayloadProperty, mapToProperty, + whenFalsy, whenTruthy, } from 'ish-core/utils/operators'; @@ -214,7 +216,9 @@ export class ProductsEffects { @Effect() loadDefaultCategoryContextForProduct$ = this.actions$.pipe( - ofRoute(/^product/), + ofRoute(productRouteFormat), + mapToParam('categoryUniqueId'), + whenFalsy(), switchMap(() => this.store.pipe( select(productsSelectors.getSelectedProduct), diff --git a/src/app/extensions/seo/store/seo/seo.effects.ts b/src/app/extensions/seo/store/seo/seo.effects.ts index b4778df880..5ef96954ce 100644 --- a/src/app/extensions/seo/store/seo/seo.effects.ts +++ b/src/app/extensions/seo/store/seo/seo.effects.ts @@ -9,7 +9,7 @@ import { debounce, distinctUntilKeyChanged, first, map, switchMap, tap } from 'r 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, productRouteFormat } 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'; @@ -77,19 +77,19 @@ export class SeoEffects { @Effect() seoProduct$ = this.actions$.pipe( - ofRoute(['product/:sku/**', 'category/:categoryUniqueId/product/:sku/**']), + ofRoute(productRouteFormat), switchMap(() => this.store.pipe( select(getSelectedProduct), whenTruthy(), map(p => (ProductHelper.isVariationProduct(p) && p.productMaster()) || p), distinctUntilKeyChanged('sku'), + whenTruthy(), map( - p => - p && - p.seoAttributes && { - canonical: generateProductRoute(p, p.defaultCategory()), - ...p.seoAttributes, + product => + product.seoAttributes && { + canonical: generateProductUrl(product), + ...product.seoAttributes, } ), whenTruthy() diff --git a/src/app/pages/app-last-routing.module.ts b/src/app/pages/app-last-routing.module.ts index cf332bb175..2c0bb5da79 100644 --- a/src/app/pages/app-last-routing.module.ts +++ b/src/app/pages/app-last-routing.module.ts @@ -1,7 +1,17 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; +import { MetaGuard } from '@ngx-meta/core'; -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), + canActivate: [MetaGuard], + }, + { 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 2f55c2a0c1..bbfd941511 100644 --- a/src/app/pages/app-routing.module.ts +++ b/src/app/pages/app-routing.module.ts @@ -31,11 +31,6 @@ const routes: Routes = [ }, }, }, - { - path: 'product', - loadChildren: () => import('./product/product-page.module').then(m => m.ProductPageModule), - canActivate: [MetaGuard], - }, { 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..e111966d9f 100644 --- a/src/app/pages/product/product-page.module.ts +++ b/src/app/pages/product/product-page.module.ts @@ -14,17 +14,7 @@ import { ProductMasterVariationsComponent } from './product-master-variations/pr import { ProductPageComponent } from './product-page.component'; import { RetailSetPartsComponent } from './retail-set-parts/retail-set-parts.component'; -const productPageRoutes: Routes = [ - { - path: ':sku', - children: [ - { - path: '**', - component: ProductPageComponent, - }, - ], - }, -]; +const productPageRoutes: Routes = [{ path: '**', component: ProductPageComponent }]; @NgModule({ imports: [RouterModule.forChild(productPageRoutes), SharedModule], 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 4b2110b686..f6f5e6b13e 100644 --- a/tslint.json +++ b/tslint.json @@ -491,6 +491,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", @@ -557,6 +560,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": "^([a-z].*)RouteFormat$", + "file": ".*/core/routing//.route\\.ts$" + }, // angular components { "name": "^([A-Z].*)PageComponent$",