diff --git a/src/app/core/pipes.module.ts b/src/app/core/pipes.module.ts index b5c25ffee0f..63ace0ee6ca 100644 --- a/src/app/core/pipes.module.ts +++ b/src/app/core/pipes.module.ts @@ -2,12 +2,12 @@ import { ModuleWithProviders, NgModule } from '@angular/core'; import { AttributeToStringPipe } from './models/attribute/attribute.pipe'; import { PricePipe } from './models/price/price.pipe'; -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 { SafeHtmlPipe } from './pipes/safe-html.pipe'; import { SanitizePipe } from './pipes/sanitize.pipe'; +import { CategoryRoutePipe } from './routing/category/category-route.pipe'; import { ProductRoutePipe } from './routing/product/product-route.pipe'; const pipes = [ diff --git a/src/app/core/pipes/category-route.pipe.spec.ts b/src/app/core/pipes/category-route.pipe.spec.ts deleted file mode 100644 index 8a37fe55c51..00000000000 --- a/src/app/core/pipes/category-route.pipe.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { TestBed } from '@angular/core/testing'; - -import { Category } from 'ish-core/models/category/category.model'; - -import { CategoryRoutePipe } from './category-route.pipe'; - -describe('Category Route Pipe', () => { - let categoryRoutePipe: CategoryRoutePipe; - let cat: Category; - - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [CategoryRoutePipe], - }); - categoryRoutePipe = TestBed.get(CategoryRoutePipe); - cat = { uniqueId: 'cate' } as Category; - }); - - it('should be created', () => { - expect(categoryRoutePipe).toBeTruthy(); - }); - - it('should generate category route for category', () => { - expect(categoryRoutePipe.transform(cat)).toEqual('/category/cate'); - }); -}); diff --git a/src/app/core/pipes/category-route.pipe.ts b/src/app/core/pipes/category-route.pipe.ts deleted file mode 100644 index c6924a1991d..00000000000 --- a/src/app/core/pipes/category-route.pipe.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Pipe, PipeTransform } from '@angular/core'; - -import { Category } from 'ish-core/models/category/category.model'; - -@Pipe({ name: 'ishCategoryRoute', pure: true }) -export class CategoryRoutePipe implements PipeTransform { - transform(category: Category): string { - return '/category/' + category.uniqueId; - } -} diff --git a/src/app/core/routing/category/category-route.pipe.ts b/src/app/core/routing/category/category-route.pipe.ts new file mode 100644 index 00000000000..43216b0fc89 --- /dev/null +++ b/src/app/core/routing/category/category-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 { generateCategoryUrl } from './category.route'; + +@Pipe({ name: 'ishCategoryRoute', pure: true }) +export class CategoryRoutePipe implements PipeTransform { + transform(category: CategoryView): string { + return generateCategoryUrl(category); + } +} diff --git a/src/app/core/routing/category/category.route.spec.ts b/src/app/core/routing/category/category.route.spec.ts new file mode 100644 index 00000000000..753caaefbac --- /dev/null +++ b/src/app/core/routing/category/category.route.spec.ts @@ -0,0 +1,139 @@ +import { UrlMatchResult, UrlSegment } from '@angular/router'; +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 { categoryTree } from 'ish-core/utils/dev/test-data-utils'; + +import { generateCategoryUrl, matchCategoryRoute, ofCategoryRoute } from './category.route'; + +describe('Category 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(generateCategoryUrl(undefined)).toMatchInlineSnapshot(`"/"`); + }); + + it('should not be a match for matcher', () => { + expect(matchCategoryRoute(wrap(generateCategoryUrl(undefined)))).toMatchInlineSnapshot(`undefined`); + }); + }); + + describe('with top level category without name', () => { + const category = createCategoryView(categoryTree([{ ...specials, name: undefined }]), specials.uniqueId); + + it('should be created', () => { + expect(generateCategoryUrl(category)).toMatchInlineSnapshot(`"/catSpecials"`); + }); + + it('should not be a match for matcher', () => { + expect(matchCategoryRoute(wrap(generateCategoryUrl(category)))).toMatchInlineSnapshot(` + Object { + "categoryUniqueId": "Specials", + } + `); + }); + }); + + describe('with top level category', () => { + const category = createCategoryView(categoryTree([specials]), specials.uniqueId); + + it('should be created', () => { + expect(generateCategoryUrl(category)).toMatchInlineSnapshot(`"/Spezielles-catSpecials"`); + }); + + it('should not be a match for matcher', () => { + expect(matchCategoryRoute(wrap(generateCategoryUrl(category)))).toMatchInlineSnapshot(` + Object { + "categoryUniqueId": "Specials", + } + `); + }); + }); + + describe('with deep category', () => { + const category = createCategoryView(categoryTree([specials, topSeller, limitedOffer]), limitedOffer.uniqueId); + + it('should be created', () => { + expect(generateCategoryUrl(category)).toMatchInlineSnapshot( + `"/Spezielles/Angebote/Black-Friday-catSpecials.TopSeller.LimitedOffer"` + ); + }); + + it('should not be a match for matcher', () => { + expect(matchCategoryRoute(wrap(generateCategoryUrl(category)))).toMatchInlineSnapshot(` + Object { + "categoryUniqueId": "Specials.TopSeller.LimitedOffer", + } + `); + }); + }); + + describe('compatibility', () => { + it('should detect category route without product after it', () => { + expect(matchCategoryRoute(wrap('/category/123'))).toHaveProperty('consumed'); + }); + + it('should not detect category route with product after it', () => { + expect(matchCategoryRoute(wrap('/category/123/product/123'))).toBeUndefined(); + }); + }); + + describe('ofCategoryRoute', () => { + it('should detect category route when categoryUniqueId is a param', () => { + const stream$ = of(new RouteNavigation({ path: 'any', params: { categoryUniqueId: '123' } })); + expect(stream$.pipe(ofCategoryRoute())).toBeObservable( + cold('(a|)', { + a: new RouteNavigation({ + params: { + categoryUniqueId: '123', + }, + path: 'any', + url: '/any', + }), + }) + ); + }); + + it('should not detect category route when categoryUniqueId is missing', () => { + const stream$ = of(new RouteNavigation({ path: 'any' })); + + expect(stream$.pipe(ofCategoryRoute())).toBeObservable(cold('|')); + }); + + it('should not detect category route when categoryUniqueId and sku are params', () => { + const stream$ = of(new RouteNavigation({ path: 'any', params: { categoryUniqueId: '123', sku: '123' } })); + + expect(stream$.pipe(ofCategoryRoute())).toBeObservable(cold('|')); + }); + }); +}); diff --git a/src/app/core/routing/category/category.route.ts b/src/app/core/routing/category/category.route.ts new file mode 100644 index 00000000000..a6e0b3333c1 --- /dev/null +++ b/src/app/core/routing/category/category.route.ts @@ -0,0 +1,66 @@ +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'; + +export function generateLocalizedCategorySlug(category: CategoryView) { + return ( + category && + category + .pathCategories() + .map(cat => cat.name) + .filter(x => x) + .map(name => name.replace(/ /g, '-')) + .join('/') + ); +} + +const categoryRouteFormat = new RegExp('^/(?!category/.*$)(.*)cat(.*)$'); + +export function matchCategoryRoute(segments: UrlSegment[]): UrlMatchResult { + // compatibility to old routes + if (segments && segments.length === 2 && segments[0].path === 'category') { + return { consumed: [] }; + } + + const url = '/' + segments.join('/'); + if (categoryRouteFormat.test(url)) { + const match = categoryRouteFormat.exec(url); + const posParams: { [id: string]: UrlSegment } = {}; + if (match[2]) { + posParams.categoryUniqueId = new UrlSegment(match[2], {}); + } + return { + consumed: [], + posParams, + }; + } + return; +} + +export function generateCategoryUrl(category: CategoryView): string { + if (!category) { + return '/'; + } + let route = '/'; + + route += generateLocalizedCategorySlug(category); + + if (route !== '/') { + route += '-'; + } + + route += `cat${category.uniqueId}`; + + return route; +} + +export function ofCategoryRoute(): MonoTypeOperatorFunction { + return source$ => + source$.pipe( + ofRoute(), + filter(action => action.payload.params && action.payload.params.categoryUniqueId && !action.payload.params.sku) + ); +} diff --git a/src/app/core/routing/product/product.route.ts b/src/app/core/routing/product/product.route.ts index c6b5844af88..ae70527f292 100644 --- a/src/app/core/routing/product/product.route.ts +++ b/src/app/core/routing/product/product.route.ts @@ -5,6 +5,7 @@ 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 { generateLocalizedCategorySlug } from 'ish-core/routing/category/category.route'; function generateProductSlug(product: ProductView) { return product && product.name ? product.name.replace(/[^a-zA-Z0-9-]+/g, '-').replace(/-+$/g, '') : undefined; @@ -49,10 +50,7 @@ export function generateProductUrl(product: ProductView, category?: CategoryView let route = '/'; if (contextCategory) { - route += contextCategory - .pathCategories() - .map(cat => cat.name.replace(/ /g, '-')) - .join('/'); + route += generateLocalizedCategorySlug(contextCategory); route += '/'; } diff --git a/src/app/core/store/shopping/categories/categories.effects.ts b/src/app/core/store/shopping/categories/categories.effects.ts index 3bcff8979e0..5210071e3f5 100644 --- a/src/app/core/store/shopping/categories/categories.effects.ts +++ b/src/app/core/store/shopping/categories/categories.effects.ts @@ -17,6 +17,7 @@ import { import { MAIN_NAVIGATION_MAX_SUB_CATEGORIES_DEPTH } from 'ish-core/configurations/injection-keys'; import { CategoryHelper } from 'ish-core/models/category/category.model'; +import { ofCategoryRoute } from 'ish-core/routing/category/category.route'; import { CategoriesService } from 'ish-core/services/categories/categories.service'; import { LoadMoreProducts } from 'ish-core/store/shopping/product-listing'; import { HttpStatusCodeService } from 'ish-core/utils/http-status-code/http-status-code.service'; @@ -135,8 +136,9 @@ export class CategoriesEffects { @Effect() productOrCategoryChanged$ = this.actions$.pipe( - ofRoute('category/:categoryUniqueId'), - mapToParam('categoryUniqueId'), + ofCategoryRoute(), + mapToParam('sku'), + whenFalsy(), switchMap(() => this.store.pipe(select(selectors.getSelectedCategory))), whenTruthy(), filter(cat => cat.hasOnlineProducts), diff --git a/src/app/extensions/seo/store/seo/seo.effects.ts b/src/app/extensions/seo/store/seo/seo.effects.ts index 712d28be0cc..736ad279a0f 100644 --- a/src/app/extensions/seo/store/seo/seo.effects.ts +++ b/src/app/extensions/seo/store/seo/seo.effects.ts @@ -9,6 +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 { ofCategoryRoute } from 'ish-core/routing/category/category.route'; 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'; @@ -55,7 +56,7 @@ export class SeoEffects { @Effect() seoCategory$ = this.actions$.pipe( - ofRoute('category/:categoryUniqueId'), + ofCategoryRoute(), debounce(() => this.actions$.pipe(ofType(CategoriesActionTypes.SelectedCategoryAvailable))), switchMap(() => this.store.pipe( diff --git a/src/app/pages/app-last-routing.module.ts b/src/app/pages/app-last-routing.module.ts index 2c0bb5da799..54af093bbee 100644 --- a/src/app/pages/app-last-routing.module.ts +++ b/src/app/pages/app-last-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { MetaGuard } from '@ngx-meta/core'; +import { matchCategoryRoute } from 'ish-core/routing/category/category.route'; import { matchProductRoute } from 'ish-core/routing/product/product.route'; const routes: Routes = [ @@ -10,6 +11,11 @@ const routes: Routes = [ loadChildren: () => import('./product/product-page.module').then(m => m.ProductPageModule), canActivate: [MetaGuard], }, + { + matcher: matchCategoryRoute, + loadChildren: () => import('./category/category-page.module').then(m => m.CategoryPageModule), + canActivate: [MetaGuard], + }, { path: '**', redirectTo: '/error' }, ]; diff --git a/src/app/pages/app-routing.module.ts b/src/app/pages/app-routing.module.ts index bbfd9415116..b1bdbf1415a 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: 'category', - loadChildren: () => import('./category/category-page.module').then(m => m.CategoryPageModule), - canActivate: [MetaGuard], - }, { path: 'account', loadChildren: () => import('./account/account-page.module').then(m => m.AccountPageModule), diff --git a/src/app/pages/category/category-navigation/category-navigation.component.spec.ts b/src/app/pages/category/category-navigation/category-navigation.component.spec.ts index 1260107425b..13fff5ab2d2 100644 --- a/src/app/pages/category/category-navigation/category-navigation.component.spec.ts +++ b/src/app/pages/category/category-navigation/category-navigation.component.spec.ts @@ -4,7 +4,7 @@ import { MockPipe } from 'ng-mocks'; import { createCategoryView } from 'ish-core/models/category-view/category-view.model'; import { Category } from 'ish-core/models/category/category.model'; -import { CategoryRoutePipe } from 'ish-core/pipes/category-route.pipe'; +import { CategoryRoutePipe } from 'ish-core/routing/category/category-route.pipe'; import { categoryTree } from 'ish-core/utils/dev/test-data-utils'; import { CategoryNavigationComponent } from './category-navigation.component'; diff --git a/src/app/pages/category/category-page.module.ts b/src/app/pages/category/category-page.module.ts index efe8c10cc1e..a983c5e4b6e 100644 --- a/src/app/pages/category/category-page.module.ts +++ b/src/app/pages/category/category-page.module.ts @@ -13,13 +13,11 @@ import { CategoryTileComponent } from './category-tile/category-tile.component'; const categoryPageRoutes: Routes = [ { - path: ':categoryUniqueId', + // compatibility to old routes + path: 'category/:categoryUniqueId', component: CategoryPageComponent, }, - { - path: ':categoryUniqueId/product', - loadChildren: () => import('../product/product-page.module').then(m => m.ProductPageModule), - }, + { path: '**', component: CategoryPageComponent }, ]; @NgModule({ diff --git a/src/app/pages/category/category-tile/category-tile.component.spec.ts b/src/app/pages/category/category-tile/category-tile.component.spec.ts index 15f1ec022a3..170e778149f 100644 --- a/src/app/pages/category/category-tile/category-tile.component.spec.ts +++ b/src/app/pages/category/category-tile/category-tile.component.spec.ts @@ -2,8 +2,10 @@ import { ComponentFixture, TestBed, async } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { MockComponent, MockPipe } from 'ng-mocks'; +import { createCategoryView } from 'ish-core/models/category-view/category-view.model'; import { Category } from 'ish-core/models/category/category.model'; -import { CategoryRoutePipe } from 'ish-core/pipes/category-route.pipe'; +import { CategoryRoutePipe } from 'ish-core/routing/category/category-route.pipe'; +import { categoryTree } from 'ish-core/utils/dev/test-data-utils'; import { CategoryImageComponent } from '../category-image/category-image.component'; @@ -41,7 +43,7 @@ describe('Category Tile Component', () => { }, ], } as Category; - component.category = category; + component.category = createCategoryView(categoryTree([category]), category.uniqueId); fixture.detectChanges(); }); diff --git a/src/app/pages/category/category-tile/category-tile.component.ts b/src/app/pages/category/category-tile/category-tile.component.ts index b601449cba4..e686f5d793f 100644 --- a/src/app/pages/category/category-tile/category-tile.component.ts +++ b/src/app/pages/category/category-tile/category-tile.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { Category } from 'ish-core/models/category/category.model'; +import { CategoryView } from 'ish-core/models/category-view/category-view.model'; /** * The Category Tile Component renders a category tile with the image of the @@ -18,5 +18,5 @@ export class CategoryTileComponent { /** * The Category to render a tile for */ - @Input() category: Category; + @Input() category: CategoryView; } diff --git a/src/app/shared/components/common/breadcrumb/breadcrumb.component.spec.ts b/src/app/shared/components/common/breadcrumb/breadcrumb.component.spec.ts index edd873f04f6..5c436aa5ce0 100644 --- a/src/app/shared/components/common/breadcrumb/breadcrumb.component.spec.ts +++ b/src/app/shared/components/common/breadcrumb/breadcrumb.component.spec.ts @@ -6,7 +6,6 @@ import { createCategoryView } from 'ish-core/models/category-view/category-view. 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 { CategoryRoutePipe } from 'ish-core/pipes/category-route.pipe'; import { categoryTree } from 'ish-core/utils/dev/test-data-utils'; import { BreadcrumbComponent } from './breadcrumb.component'; @@ -21,7 +20,6 @@ describe('Breadcrumb Component', () => { TestBed.configureTestingModule({ declarations: [BreadcrumbComponent], imports: [RouterTestingModule, TranslateModule.forRoot()], - providers: [CategoryRoutePipe], }); fixture = TestBed.createComponent(BreadcrumbComponent); component = fixture.componentInstance; diff --git a/src/app/shared/components/common/breadcrumb/breadcrumb.component.ts b/src/app/shared/components/common/breadcrumb/breadcrumb.component.ts index 0b5f5725a26..aacfd77f952 100644 --- a/src/app/shared/components/common/breadcrumb/breadcrumb.component.ts +++ b/src/app/shared/components/common/breadcrumb/breadcrumb.component.ts @@ -3,7 +3,7 @@ import { ChangeDetectionStrategy, Component, DoCheck, Input } from '@angular/cor import { BreadcrumbItem } from 'ish-core/models/breadcrumb-item/breadcrumb-item.interface'; import { CategoryView } from 'ish-core/models/category-view/category-view.model'; import { ProductView } from 'ish-core/models/product-view/product-view.model'; -import { CategoryRoutePipe } from 'ish-core/pipes/category-route.pipe'; +import { generateCategoryUrl } from 'ish-core/routing/category/category.route'; @Component({ selector: 'ish-breadcrumb', @@ -19,8 +19,6 @@ export class BreadcrumbComponent implements DoCheck { @Input() account: boolean; @Input() trail: BreadcrumbItem[] = []; - constructor(private categoryRoutePipe: CategoryRoutePipe) {} - private buildTrailFromCategoryOrProduct(category: CategoryView, product: ProductView) { const trail = []; @@ -30,7 +28,7 @@ export class BreadcrumbComponent implements DoCheck { trail.push( ...usedCategory.pathCategories().map(cat => ({ text: cat.name, - link: this.categoryRoutePipe.transform(cat), + link: generateCategoryUrl(cat), })) ); } diff --git a/src/app/shell/header/header-navigation/header-navigation.component.spec.ts b/src/app/shell/header/header-navigation/header-navigation.component.spec.ts index 387f968c930..6b42a08d3a2 100644 --- a/src/app/shell/header/header-navigation/header-navigation.component.spec.ts +++ b/src/app/shell/header/header-navigation/header-navigation.component.spec.ts @@ -8,7 +8,7 @@ import { instance, mock, when } from 'ts-mockito'; import { ShoppingFacade } from 'ish-core/facades/shopping.facade'; import { createCategoryView } from 'ish-core/models/category-view/category-view.model'; import { Category } from 'ish-core/models/category/category.model'; -import { CategoryRoutePipe } from 'ish-core/pipes/category-route.pipe'; +import { CategoryRoutePipe } from 'ish-core/routing/category/category-route.pipe'; import { categoryTree } from 'ish-core/utils/dev/test-data-utils'; import { SubCategoryNavigationComponent } from 'ish-shell/header/sub-category-navigation/sub-category-navigation.component'; @@ -64,9 +64,9 @@ describe('Header Navigation Component', () => { diff --git a/src/app/shell/header/sub-category-navigation/sub-category-navigation.component.spec.ts b/src/app/shell/header/sub-category-navigation/sub-category-navigation.component.spec.ts index 43928174ae8..613cfe55ca2 100644 --- a/src/app/shell/header/sub-category-navigation/sub-category-navigation.component.spec.ts +++ b/src/app/shell/header/sub-category-navigation/sub-category-navigation.component.spec.ts @@ -6,7 +6,7 @@ import { MockComponent } from 'ng-mocks'; import { MAIN_NAVIGATION_MAX_SUB_CATEGORIES_DEPTH } from 'ish-core/configurations/injection-keys'; import { createCategoryView } from 'ish-core/models/category-view/category-view.model'; import { Category } from 'ish-core/models/category/category.model'; -import { CategoryRoutePipe } from 'ish-core/pipes/category-route.pipe'; +import { CategoryRoutePipe } from 'ish-core/routing/category/category-route.pipe'; import { categoryTree } from 'ish-core/utils/dev/test-data-utils'; import { SubCategoryNavigationComponent } from './sub-category-navigation.component';