diff --git a/e2e/cypress/integration/pages/shopping/family.page.ts b/e2e/cypress/integration/pages/shopping/family.page.ts index 8a50568668..18a5ad8475 100644 --- a/e2e/cypress/integration/pages/shopping/family.page.ts +++ b/e2e/cypress/integration/pages/shopping/family.page.ts @@ -10,6 +10,6 @@ export class FamilyPage { readonly productList = new ProductListModule('ish-product-listing'); static navigateTo(categoryUniqueId: string, page?: number) { - cy.visit(`/category/${categoryUniqueId}${page ? `?page=${page}` : ''}`); + cy.visit(`/cat${categoryUniqueId}${page ? `?page=${page}` : ''}`); } } diff --git a/e2e/cypress/integration/pages/shopping/product-detail.page.ts b/e2e/cypress/integration/pages/shopping/product-detail.page.ts index 70da4a1090..f2e97a0d5d 100644 --- a/e2e/cypress/integration/pages/shopping/product-detail.page.ts +++ b/e2e/cypress/integration/pages/shopping/product-detail.page.ts @@ -15,9 +15,9 @@ export class ProductDetailPage { static navigateTo(sku: string, categoryUniqueId?: string) { if (categoryUniqueId) { - cy.visit(`/category/${categoryUniqueId}/product/${sku}`); + cy.visit(`/sku${sku}-cat${categoryUniqueId}`); } else { - cy.visit(`/product/${sku}`); + cy.visit(`/sku${sku}`); } } diff --git a/e2e/test-universal.sh b/e2e/test-universal.sh index a254b84347..30c395eed4 100644 --- a/e2e/test-universal.sh +++ b/e2e/test-universal.sh @@ -15,12 +15,12 @@ universalTest() { } universalTest 1 "${PWA_BASE_URL}/" "router-outlet>Notebooks and PCs" -universalTest 6 "${PWA_BASE_URL}/category/Computers.1835" "

PCs

" -universalTest 7 "${PWA_BASE_URL}/category/Computers.1835.151" "add-to-compare" +universalTest 5 "${PWA_BASE_URL}/catComputers.1835" "

Notebooks and PCs

" +universalTest 6 "${PWA_BASE_URL}/catComputers.1835" "

PCs

" +universalTest 7 "${PWA_BASE_URL}/catComputers.1835.151" "add-to-compare" universalTest 8 "${PWA_BASE_URL}/home" "intershop-pwa-state" universalTest 9 "${PWA_BASE_URL}/home" "&q;baseURL&q;:" universalTest 10 "${PWA_BASE_URL}/home" " { .pipe( createModule(schematicRunner, { name: 'shared' }), createModule(schematicRunner, { name: 'shell' }), - createAppNotFoundRoutingModule(schematicRunner) + createAppLastRoutingModule(schematicRunner) ) .toPromise(); }); @@ -55,7 +55,7 @@ describe('Extension Schematic', () => { import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; - import { AppNotFoundRoutingModule } from './pages/app-not-found-routing.module'; + import { AppLastRoutingModule } from './pages/app-last-routing.module'; import { FooRoutingModule } from './extensions/foo/pages/foo-routing.module'; @NgModule({ @@ -65,7 +65,7 @@ describe('Extension Schematic', () => { imports: [ BrowserModule, AppRoutingModule, - FooRoutingModule, AppNotFoundRoutingModule + FooRoutingModule, AppLastRoutingModule ], providers: [], bootstrap: [AppComponent] @@ -115,7 +115,7 @@ describe('Extension Schematic', () => { `); }); - it('should throw if app module does not contain AppNotFoundRoutingModule', done => { + it('should throw if app module does not contain AppLastRoutingModule', done => { appTree.overwrite( '/projects/bar/src/app/app.module.ts', `import { BrowserModule } from '@angular/platform-browser'; @@ -143,7 +143,7 @@ export class AppModule { } schematicRunner.runSchematicAsync('extension', options, appTree).subscribe(noop, err => { expect(err).toMatchInlineSnapshot( - `[Error: did not find 'AppNotFoundRoutingModule' in /projects/bar/src/app/app.module.ts]` + `[Error: did not find 'AppLastRoutingModule' in /projects/bar/src/app/app.module.ts]` ); done(); }); diff --git a/schematics/src/store/factory_spec.ts b/schematics/src/store/factory_spec.ts index 3e91b2391e..c0e5dc8e42 100644 --- a/schematics/src/store/factory_spec.ts +++ b/schematics/src/store/factory_spec.ts @@ -2,7 +2,7 @@ import { UnitTestTree } from '@angular-devkit/schematics/testing'; import { mergeMap } from 'rxjs/operators'; import { - createAppNotFoundRoutingModule, + createAppLastRoutingModule, createApplication, createModule, createSchematicRunner, @@ -23,7 +23,7 @@ describe('Store Schematic', () => { .pipe( createModule(schematicRunner, { name: 'shared' }), createModule(schematicRunner, { name: 'shell' }), - createAppNotFoundRoutingModule(schematicRunner), + createAppLastRoutingModule(schematicRunner), mergeMap(tree => schematicRunner.runSchematicAsync('extension', { name: 'feature', project: 'bar' }, tree)) ) .toPromise(); diff --git a/schematics/src/utils/testHelper.js b/schematics/src/utils/testHelper.js index cac576bd52..7f3b7536bb 100644 --- a/schematics/src/utils/testHelper.js +++ b/schematics/src/utils/testHelper.js @@ -29,12 +29,12 @@ function createModule(schematicRunner, options) { return (source$) => source$.pipe(operators_1.switchMap(tree => schematicRunner.runSchematicAsync('module', Object.assign({}, options, { project: 'bar' }), tree))); } exports.createModule = createModule; -function createAppNotFoundRoutingModule(schematicRunner) { +function createAppLastRoutingModule(schematicRunner) { return (source$) => source$.pipe(operators_1.switchMap(tree => schematicRunner.runExternalSchematicAsync('@schematics/angular', 'module', { - name: 'pages/app-not-found-routing', + name: 'pages/app-last-routing', flat: true, module: 'app.module', project: 'bar', }, tree))); } -exports.createAppNotFoundRoutingModule = createAppNotFoundRoutingModule; +exports.createAppLastRoutingModule = createAppLastRoutingModule; diff --git a/schematics/src/utils/testHelper.ts b/schematics/src/utils/testHelper.ts index a7b79abdf8..de45108a88 100644 --- a/schematics/src/utils/testHelper.ts +++ b/schematics/src/utils/testHelper.ts @@ -43,7 +43,7 @@ export function createModule( source$.pipe(switchMap(tree => schematicRunner.runSchematicAsync('module', { ...options, project: 'bar' }, tree))); } -export function createAppNotFoundRoutingModule(schematicRunner: SchematicTestRunner) { +export function createAppLastRoutingModule(schematicRunner: SchematicTestRunner) { return (source$: Observable) => source$.pipe( switchMap(tree => @@ -51,7 +51,7 @@ export function createAppNotFoundRoutingModule(schematicRunner: SchematicTestRun '@schematics/angular', 'module', { - name: 'pages/app-not-found-routing', + name: 'pages/app-last-routing', flat: true, module: 'app.module', project: 'bar', diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 51bbd18658..f3e47f0e33 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -6,7 +6,7 @@ import { CoreModule } from 'ish-core/core.module'; import { AppComponent } from './app.component'; import { QuotingRoutingModule } from './extensions/quoting/pages/quoting-routing.module'; -import { AppNotFoundRoutingModule } from './pages/app-not-found-routing.module'; +import { AppLastRoutingModule } from './pages/app-last-routing.module'; import { AppRoutingModule } from './pages/app-routing.module'; import { ShellModule } from './shell/shell.module'; @@ -19,7 +19,7 @@ import { ShellModule } from './shell/shell.module'; ShellModule, AppRoutingModule, QuotingRoutingModule, - AppNotFoundRoutingModule, + AppLastRoutingModule, ], bootstrap: [AppComponent], }) diff --git a/src/app/core/directives/server-html.directive.spec.ts b/src/app/core/directives/server-html.directive.spec.ts index a06640c0d8..82768acae8 100644 --- a/src/app/core/directives/server-html.directive.spec.ts +++ b/src/app/core/directives/server-html.directive.spec.ts @@ -38,7 +38,7 @@ describe('Server Html Directive', () => { it('should transform the given links to routing links', () => { expect(element).toMatchInlineSnapshot(` @@ -80,16 +80,16 @@ describe('Server Html Directive', () => { it('should transform the given media object source to the correct source', () => { expect(element).toMatchInlineSnapshot(` -
- -
- `); +
+ +
+ `); }); }); diff --git a/src/app/core/models/basket-feedback/basket-feedback.model.ts b/src/app/core/models/basket-feedback/basket-feedback.model.ts index efbcbda7d5..bf033f375f 100644 --- a/src/app/core/models/basket-feedback/basket-feedback.model.ts +++ b/src/app/core/models/basket-feedback/basket-feedback.model.ts @@ -1,5 +1,5 @@ import { LineItem } from 'ish-core/models/line-item/line-item.model'; -import { Product } from 'ish-core/models/product/product.model'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; export interface BasketFeedback { code: string; @@ -13,6 +13,6 @@ export interface BasketFeedback { } export interface BasketFeedbackView extends BasketFeedback { - product?: Product; + product?: ProductView; lineItem?: LineItem; } diff --git a/src/app/core/models/basket/basket.model.ts b/src/app/core/models/basket/basket.model.ts index 790900611f..e83cb01837 100644 --- a/src/app/core/models/basket/basket.model.ts +++ b/src/app/core/models/basket/basket.model.ts @@ -7,6 +7,7 @@ import { BasketTotal } from 'ish-core/models/basket-total/basket-total.model'; import { BasketValidationResultType } from 'ish-core/models/basket-validation/basket-validation.model'; import { LineItem, LineItemView } from 'ish-core/models/line-item/line-item.model'; import { Payment } from 'ish-core/models/payment/payment.model'; +import { createProductView } from 'ish-core/models/product-view/product-view.model'; import { VariationProductMaster } from 'ish-core/models/product/product-variation-master.model'; import { VariationProduct } from 'ish-core/models/product/product-variation.model'; import { Product } from 'ish-core/models/product/product.model'; @@ -33,7 +34,7 @@ export interface Basket extends AbstractBasket {} export interface BasketView extends AbstractBasket {} export const createBasketView = memoize( - (basket, products, validationResults, basketInfo): BasketView => { + (basket, products, validationResults, basketInfo, categoryTree): BasketView => { if (!basket) { return; } @@ -43,7 +44,7 @@ export const createBasketView = memoize( lineItems: basket.lineItems ? basket.lineItems.map(li => ({ ...li, - product: products[li.productSKU], + product: createProductView(products[li.productSKU], categoryTree), name: products && products[li.productSKU] ? products[li.productSKU].name : undefined, inStock: products && products[li.productSKU] ? products[li.productSKU].inStock : undefined, availability: products && products[li.productSKU] ? products[li.productSKU].availability : undefined, diff --git a/src/app/core/models/content-view/content-view.helper.spec.ts b/src/app/core/models/content-view/content-view.helper.spec.ts index 8f7e43fda3..380dd7f5f5 100644 --- a/src/app/core/models/content-view/content-view.helper.spec.ts +++ b/src/app/core/models/content-view/content-view.helper.spec.ts @@ -9,7 +9,7 @@ describe('Content View Helper', () => { [ { input: 'route://category/Computers', expected: '/category/Computers' }, { input: 'route://category/Home-Entertainment.SmartHome', expected: '/category/Home-Entertainment.SmartHome' }, - { input: 'product://201807195@inSPIRED-inTRONICS', expected: '/product/201807195' }, + { input: 'product://201807195@inSPIRED-inTRONICS', expected: '/sku201807195' }, ], ({ input, expected }) => { it(`should transform ${input} to ${expected}`, () => { diff --git a/src/app/core/models/line-item/line-item.model.ts b/src/app/core/models/line-item/line-item.model.ts index 6347843072..297761c6ff 100644 --- a/src/app/core/models/line-item/line-item.model.ts +++ b/src/app/core/models/line-item/line-item.model.ts @@ -1,7 +1,7 @@ import { BasketFeedback } from 'ish-core/models/basket-feedback/basket-feedback.model'; import { BasketRebate } from 'ish-core/models/basket-rebate/basket-rebate.model'; import { Price } from 'ish-core/models/price/price.model'; -import { Product } from 'ish-core/models/product/product.model'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; export interface LineItem { id: string; @@ -45,7 +45,7 @@ export interface LineItem { } export interface LineItemView extends LineItem { - product: Product; + product: ProductView; validationError?: BasketFeedback; info?: BasketFeedback; } diff --git a/src/app/core/models/order/order.model.ts b/src/app/core/models/order/order.model.ts index c7e011d92b..0f4e7b302b 100644 --- a/src/app/core/models/order/order.model.ts +++ b/src/app/core/models/order/order.model.ts @@ -2,6 +2,7 @@ import { Dictionary } from '@ngrx/entity'; import { memoize } from 'lodash-es'; import { Basket, BasketView } from 'ish-core/models/basket/basket.model'; +import { createProductView } from 'ish-core/models/product-view/product-view.model'; import { VariationProductMaster } from 'ish-core/models/product/product-variation-master.model'; import { VariationProduct } from 'ish-core/models/product/product-variation.model'; import { Product } from 'ish-core/models/product/product.model'; @@ -26,7 +27,7 @@ export interface Order extends Basket, AbstractOrder {} export interface OrderView extends BasketView, AbstractOrder {} export const createOrderView = memoize( - (order, products): OrderView => { + (order, products, categoryTree): OrderView => { if (!order) { return; } @@ -36,7 +37,7 @@ export const createOrderView = memoize( lineItems: order.lineItems ? order.lineItems.map(li => ({ ...li, - product: products ? products[li.productSKU] : undefined, + product: products ? createProductView(products[li.productSKU], categoryTree) : undefined, })) : [], }; diff --git a/src/app/core/models/product-view/product-view.model.ts b/src/app/core/models/product-view/product-view.model.ts index 884519e758..e0eccf65b5 100644 --- a/src/app/core/models/product-view/product-view.model.ts +++ b/src/app/core/models/product-view/product-view.model.ts @@ -58,7 +58,7 @@ export function createVariationProductMasterView( : () => [], defaultVariation: product.defaultVariationSKU ? once(() => createVariationProductView(entities[product.defaultVariationSKU], entities, tree)) - : undefined, + : () => undefined, }; } diff --git a/src/app/core/pipes.module.ts b/src/app/core/pipes.module.ts index 298c51d9c1..63ace0ee6c 100644 --- a/src/app/core/pipes.module.ts +++ b/src/app/core/pipes.module.ts @@ -2,13 +2,13 @@ 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 { ProductRoutePipe } from './pipes/product-route.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 = [ AttributeToStringPipe, 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 8a37fe55c5..0000000000 --- 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 c6924a1991..0000000000 --- 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/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/category/category-route.pipe.ts b/src/app/core/routing/category/category-route.pipe.ts new file mode 100644 index 0000000000..43216b0fc8 --- /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 0000000000..6637cdc90c --- /dev/null +++ b/src/app/core/routing/category/category.route.spec.ts @@ -0,0 +1,176 @@ +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 { categoryTree } from 'ish-core/utils/dev/test-data-utils'; + +import { + generateCategoryUrl, + generateLocalizedCategorySlug, + 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 }), {}) + ), + }); + + 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(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(`"/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('additional URL params', () => { + it('should ignore additional URL params when supplied', () => { + const category = createCategoryView(categoryTree([specials, topSeller, limitedOffer]), limitedOffer.uniqueId); + expect(matchCategoryRoute(wrap(generateCategoryUrl(category) + ';lang=de_DE;redirect=1'))).toMatchInlineSnapshot(` + Object { + "categoryUniqueId": "Specials.TopSeller.LimitedOffer", + } + `); + }); + }); + + 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('|')); + }); + }); + + describe('generateLocalizedCategorySlug', () => { + it('should generate slug for top level category', () => { + const category = createCategoryView(categoryTree([specials]), specials.uniqueId); + expect(generateLocalizedCategorySlug(category)).toMatchInlineSnapshot(`"Spezielles"`); + }); + + it('should generate slug for deep category', () => { + const category = createCategoryView(categoryTree([specials, topSeller, limitedOffer]), limitedOffer.uniqueId); + expect(generateLocalizedCategorySlug(category)).toMatchInlineSnapshot(`"Black-Friday"`); + }); + + it('should return empty string when category is unavailable', () => { + expect(generateLocalizedCategorySlug(undefined)).toMatchInlineSnapshot(`""`); + }); + }); +}); 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 0000000000..4729ce8b45 --- /dev/null +++ b/src/app/core/routing/category/category.route.ts @@ -0,0 +1,62 @@ +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) { + if (!category || !category.categoryPath.length) { + return ''; + } + const lastCat = category.pathCategories()[category.categoryPath.length - 1].name; + return lastCat ? lastCat.replace(/ /g, '-') : ''; +} + +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.map(s => s.path).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.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..87512d7742 --- /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( + `"/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( + `"/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( + `"/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( + `"/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..86f3729eac --- /dev/null +++ b/src/app/core/routing/product/product.route.ts @@ -0,0 +1,91 @@ +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'; +import { generateLocalizedCategorySlug } from 'ish-core/routing/category/category.route'; + +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 += generateLocalizedCategorySlug(contextCategory); + 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/checkout/basket/basket.effects.spec.ts b/src/app/core/store/checkout/basket/basket.effects.spec.ts index 4d06554762..03beaaaf52 100644 --- a/src/app/core/store/checkout/basket/basket.effects.spec.ts +++ b/src/app/core/store/checkout/basket/basket.effects.spec.ts @@ -423,11 +423,7 @@ describe('Basket Effects', () => { describe('routeListenerForResettingBasketErrors$', () => { it('should fire ResetBasketErrors when route basket or checkout/* is navigated', () => { - const action = new RouteNavigation({ - path: 'checkout/payment', - params: {}, - queryParams: {}, - }); + const action = new RouteNavigation({ path: 'checkout/payment' }); const expected = new basketActions.ResetBasketErrors(); actions$ = hot('a', { a: action }); @@ -435,14 +431,14 @@ describe('Basket Effects', () => { }); it('should not fire ResetBasketErrors when route basket or checkout/* is navigated with query param error=true', () => { - const action = new RouteNavigation({ path: 'checkout/payment', params: {}, queryParams: { error: true } }); + const action = new RouteNavigation({ path: 'checkout/payment', queryParams: { error: true } }); actions$ = hot('a', { a: action }); expect(effects.routeListenerForResettingBasketErrors$).toBeObservable(cold('-')); }); it('should not fire ResetBasketErrors when route /something is navigated', () => { - const action = new RouteNavigation({ path: 'something', params: {}, queryParams: {} }); + const action = new RouteNavigation({ path: 'something' }); actions$ = hot('a', { a: action }); expect(effects.routeListenerForResettingBasketErrors$).toBeObservable(cold('-')); diff --git a/src/app/core/store/checkout/basket/basket.selectors.ts b/src/app/core/store/checkout/basket/basket.selectors.ts index 4e7ded8192..8dd3f48063 100644 --- a/src/app/core/store/checkout/basket/basket.selectors.ts +++ b/src/app/core/store/checkout/basket/basket.selectors.ts @@ -4,7 +4,9 @@ import { isEqual } from 'lodash-es'; import { AddressHelper } from 'ish-core/models/address/address.helper'; import { BasketValidationResultType } from 'ish-core/models/basket-validation/basket-validation.model'; import { BasketView, createBasketView } from 'ish-core/models/basket/basket.model'; +import { createProductView } from 'ish-core/models/product-view/product-view.model'; import { getCheckoutState } from 'ish-core/store/checkout/checkout-store'; +import { getCategoryTree } from 'ish-core/store/shopping/categories'; import { getProductEntities } from 'ish-core/store/shopping/products'; import { getLoggedInCustomer } from 'ish-core/store/user'; @@ -16,7 +18,8 @@ const getBasketState = createSelector( export const getBasketValidationResults = createSelector( getBasketState, getProductEntities, - (basket, products): BasketValidationResultType => { + getCategoryTree, + (basket, products, categoryTree): BasketValidationResultType => { if (!basket || !basket.validationResults) { return; } @@ -27,7 +30,7 @@ export const getBasketValidationResults = createSelector( infos: basketResults.infos ? basketResults.infos.map(info => ({ ...info, - product: info.parameters && products[info.parameters.productSku], + product: info.parameters && createProductView(products[info.parameters.productSku], categoryTree), })) : [], errors: basketResults.errors @@ -41,7 +44,10 @@ export const getBasketValidationResults = createSelector( error.parameters && error.parameters.lineItemId && basket.basket.lineItems.find(item => item.id === error.parameters.lineItemId) && - products[basket.basket.lineItems.find(item => item.id === error.parameters.lineItemId).productSKU], + createProductView( + products[basket.basket.lineItems.find(item => item.id === error.parameters.lineItemId).productSKU], + categoryTree + ), })) : [], }; @@ -61,8 +67,9 @@ export const getCurrentBasket = createSelector( getProductEntities, getBasketValidationResults, getBasketInfo, - (basket, products, validationResults, basketInfo): BasketView => - createBasketView(basket.basket, products, validationResults, basketInfo) + getCategoryTree, + (basket, products, validationResults, basketInfo, categoryTree): BasketView => + createBasketView(basket.basket, products, validationResults, basketInfo, categoryTree) ); export const getCurrentBasketId = createSelector( diff --git a/src/app/core/store/checkout/viewconf/viewconf.integration.spec.ts b/src/app/core/store/checkout/viewconf/viewconf.integration.spec.ts index e6ddfaf65b..59e522a0a3 100644 --- a/src/app/core/store/checkout/viewconf/viewconf.integration.spec.ts +++ b/src/app/core/store/checkout/viewconf/viewconf.integration.spec.ts @@ -30,7 +30,7 @@ describe('Viewconf Integration', () => { expect(getCheckoutStep(store$.state)).toBe(2); - store$.dispatch(new RouteNavigation({ path: 'checkout', data: {} })); + store$.dispatch(new RouteNavigation({ path: 'checkout' })); expect(getCheckoutStep(store$.state)).toBeUndefined(); }); diff --git a/src/app/core/store/error/error.reducer.spec.ts b/src/app/core/store/error/error.reducer.spec.ts index a0f336d273..db204380bb 100644 --- a/src/app/core/store/error/error.reducer.spec.ts +++ b/src/app/core/store/error/error.reducer.spec.ts @@ -46,7 +46,7 @@ describe('Error Reducer', () => { }, { state: { current: {}, type: ErrorActionTypes.TimeoutError }, - action: new RouteNavigation({ path: 'error', params: {}, queryParams: {} }), + action: new RouteNavigation({ path: 'error' }), expected: { current: {}, type: ErrorActionTypes.TimeoutError }, }, ]; diff --git a/src/app/core/store/orders/orders.effects.spec.ts b/src/app/core/store/orders/orders.effects.spec.ts index ec600e6e08..12d7c7b043 100644 --- a/src/app/core/store/orders/orders.effects.spec.ts +++ b/src/app/core/store/orders/orders.effects.spec.ts @@ -290,7 +290,6 @@ describe('Orders Effects', () => { const action = new RouteNavigation({ path: 'account/orders/:orderId', params: { orderId }, - queryParams: {}, }); const expected = new orderActions.SelectOrder({ orderId }); @@ -299,7 +298,7 @@ describe('Orders Effects', () => { }); it('should not fire SelectOrder when route /something is navigated', () => { - const action = new RouteNavigation({ path: 'something', params: {}, queryParams: {} }); + const action = new RouteNavigation({ path: 'something' }); actions$ = hot('a', { a: action }); expect(effects.routeListenerForSelectingOrder$).toBeObservable(cold('-')); diff --git a/src/app/core/store/orders/orders.selectors.spec.ts b/src/app/core/store/orders/orders.selectors.spec.ts index 5c9950b9f4..5c6cb5630d 100644 --- a/src/app/core/store/orders/orders.selectors.spec.ts +++ b/src/app/core/store/orders/orders.selectors.spec.ts @@ -107,7 +107,7 @@ describe('Orders Selectors', () => { expect(loadedOrders[1].documentNo).toEqual(orders[1].documentNo); expect(loadedOrders[1].lineItems).toHaveLength(1); expect(loadedOrders[1].lineItems[0].id).toEqual('test2'); - expect(loadedOrders[1].lineItems[0].product).toEqual({ sku: 'sku' }); + expect(loadedOrders[1].lineItems[0].product).toHaveProperty('sku', 'sku'); }); }); @@ -146,7 +146,7 @@ describe('Orders Selectors', () => { expect(loadedOrder.documentNo).toEqual(orders[0].documentNo); expect(loadedOrder.lineItems).toHaveLength(1); expect(loadedOrder.lineItems[0].id).toEqual('test'); - expect(loadedOrder.lineItems[0].product).toEqual({ sku: 'sku' }); + expect(loadedOrder.lineItems[0].product).toHaveProperty('sku', 'sku'); }); }); diff --git a/src/app/core/store/orders/orders.selectors.ts b/src/app/core/store/orders/orders.selectors.ts index f9297fbf10..440fe3cbbc 100644 --- a/src/app/core/store/orders/orders.selectors.ts +++ b/src/app/core/store/orders/orders.selectors.ts @@ -2,6 +2,7 @@ import { createSelector } from '@ngrx/store'; import { OrderView, createOrderView } from 'ish-core/models/order/order.model'; import { getCoreState } from 'ish-core/store/core-store'; +import { getCategoryTree } from 'ish-core/store/shopping/categories'; import { getProductEntities } from 'ish-core/store/shopping/products'; import { orderAdapter } from './orders.reducer'; @@ -22,20 +23,25 @@ export const getSelectedOrder = createSelector( getOrderEntities, getSelectedOrderId, getProductEntities, - (entities, id, products): OrderView => (id && entities[id] ? createOrderView(entities[id], products) : undefined) + getCategoryTree, + (entities, id, products, categoryTree): OrderView => + id && entities[id] ? createOrderView(entities[id], products, categoryTree) : undefined ); export const getOrders = createSelector( getOrdersInternal, getProductEntities, - (orders, products): OrderView[] => (!orders ? [] : orders.map(e => createOrderView(e, products))) + getCategoryTree, + (orders, products, categoryTree): OrderView[] => + !orders ? [] : orders.map(e => createOrderView(e, products, categoryTree)) ); export const getOrder = createSelector( getOrdersInternal, getProductEntities, - (entities, products, props: { orderId: string }): OrderView => - createOrderView(entities.find(e => e.id === props.orderId), products) + getCategoryTree, + (entities, products, categoryTree, props: { orderId: string }): OrderView => + createOrderView(entities.find(e => e.id === props.orderId), products, categoryTree) ); export const getOrdersLoading = createSelector( diff --git a/src/app/core/store/shopping/categories/categories.effects.spec.ts b/src/app/core/store/shopping/categories/categories.effects.spec.ts index e905a6093a..d104329c6c 100644 --- a/src/app/core/store/shopping/categories/categories.effects.spec.ts +++ b/src/app/core/store/shopping/categories/categories.effects.spec.ts @@ -79,7 +79,6 @@ describe('Categories Effects', () => { const action = new RouteNavigation({ path: 'category/:categoryUniqueId', params: { categoryUniqueId: 'dummy' }, - queryParams: {}, }); const expected = new fromActions.SelectCategory({ categoryId: 'dummy' }); @@ -91,7 +90,6 @@ describe('Categories Effects', () => { const action = new RouteNavigation({ path: 'category/:categoryUniqueId/product/:sku', params: { categoryUniqueId: 'dummy', sku: 'foobar' }, - queryParams: {}, }); const expected = new fromActions.SelectCategory({ categoryId: 'dummy' }); @@ -100,11 +98,7 @@ describe('Categories Effects', () => { }); it('should not trigger SelectCategory when /something is visited', () => { - const action = new RouteNavigation({ - path: 'something', - params: {}, - queryParams: {}, - }); + const action = new RouteNavigation({ path: 'something' }); actions$ = hot('a', { a: action }); expect(effects.routeListenerForSelectingCategory$).toBeObservable(cold('-')); @@ -114,7 +108,6 @@ describe('Categories Effects', () => { const action = new RouteNavigation({ path: 'category/:categoryUniqueId', params: { categoryUniqueId: 'dummy' }, - queryParams: {}, }); const expected = new fromActions.SelectCategory({ categoryId: 'dummy' }); @@ -342,7 +335,6 @@ describe('Categories Effects', () => { a: new RouteNavigation({ path: 'category/:categoryUniqueId', params: { categoryUniqueId: category.uniqueId }, - queryParams: {}, }), }); @@ -359,7 +351,6 @@ describe('Categories Effects', () => { a: new RouteNavigation({ path: 'category/:categoryUniqueId/product/:sku', params: { categoryUniqueId: category.uniqueId, sku: 'dummy' }, - queryParams: {}, }), b: new fromActions.SelectedCategoryAvailable({ categoryId: category.uniqueId }), }); diff --git a/src/app/core/store/shopping/categories/categories.effects.ts b/src/app/core/store/shopping/categories/categories.effects.ts index 3bcff8979e..5210071e3f 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/core/store/shopping/products/products.effects.spec.ts b/src/app/core/store/shopping/products/products.effects.spec.ts index e9100d8481..b55e7cbb20 100644 --- a/src/app/core/store/shopping/products/products.effects.spec.ts +++ b/src/app/core/store/shopping/products/products.effects.spec.ts @@ -307,7 +307,6 @@ describe('Products Effects', () => { const action = new RouteNavigation({ path: 'category/:categoryUniqueId/product/:sku', params: { categoryUniqueId: 'dummy', sku: 'foobar' }, - queryParams: {}, }); const expected = new fromActions.SelectProduct({ sku: 'foobar' }); @@ -319,7 +318,6 @@ describe('Products Effects', () => { const action = new RouteNavigation({ path: 'product/:sku', params: { sku: 'foobar' }, - queryParams: {}, }); const expected = new fromActions.SelectProduct({ sku: 'foobar' }); @@ -328,7 +326,7 @@ describe('Products Effects', () => { }); it('should not fire SelectProduct when route /something is navigated', () => { - const action = new RouteNavigation({ path: 'something', params: {}, queryParams: {} }); + const action = new RouteNavigation({ path: 'something' }); actions$ = hot('a', { a: action }); expect(effects.routeListenerForSelectingProducts$).toBeObservable(cold('-')); @@ -349,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); @@ -364,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/core/store/shopping/search/search.effects.spec.ts b/src/app/core/store/shopping/search/search.effects.spec.ts index 606e90bc7e..77db119c34 100644 --- a/src/app/core/store/shopping/search/search.effects.spec.ts +++ b/src/app/core/store/shopping/search/search.effects.spec.ts @@ -93,7 +93,6 @@ describe('Search Effects', () => { const action = new RouteNavigation({ path: 'search/:searchTerm', params: { searchTerm: 'dummy' }, - queryParams: [], }); actions$ = hot('a-a-|', { a: action }); diff --git a/src/app/core/store/shopping/shopping-store.spec.ts b/src/app/core/store/shopping/shopping-store.spec.ts index 6395798ea5..2f568154bd 100644 --- a/src/app/core/store/shopping/shopping-store.spec.ts +++ b/src/app/core/store/shopping/shopping-store.spec.ts @@ -234,6 +234,7 @@ describe('Shopping Store', () => { params: {} queryParams: {} data: {} + url: "/home" path: "home" [Viewconf Internal] Set Device Type: deviceType: "pc" @@ -269,6 +270,7 @@ describe('Shopping Store', () => { params: {"categoryUniqueId":"A.123"} queryParams: {} data: {} + url: "/category/A.123" path: "category/:categoryUniqueId" [Shopping] Select Category: categoryId: "A.123" @@ -321,6 +323,7 @@ describe('Shopping Store', () => { params: {"searchTerm":"something"} queryParams: {} data: {} + url: "/search/something" path: "search/:searchTerm" [Shopping] Set Search Term: searchTerm: "something" @@ -364,6 +367,7 @@ describe('Shopping Store', () => { params: {"sku":"P2"} queryParams: {} data: {} + url: "/product/P2" path: "product/:sku" [Shopping] Select Product: sku: "P2" @@ -398,6 +402,7 @@ describe('Shopping Store', () => { params: {"categoryUniqueId":"A.123"} queryParams: {} data: {} + url: "/category/A.123" path: "category/:categoryUniqueId" [Viewconf Internal] Set Device Type: deviceType: "pc" @@ -441,6 +446,7 @@ describe('Shopping Store', () => { params: {} queryParams: {} data: {} + url: "/compare" path: "compare" [Shopping] Deselect Category `); @@ -471,6 +477,7 @@ describe('Shopping Store', () => { params: {"categoryUniqueId":"A.123.456"} queryParams: {} data: {} + url: "/category/A.123.456" path: "category/:categoryUniqueId" [Viewconf Internal] Set Device Type: deviceType: "pc" @@ -546,6 +553,7 @@ describe('Shopping Store', () => { params: {"categoryUniqueId":"A.123.456","sku":"P1"} queryParams: {} data: {} + url: "/category/A.123.456/product/P1" path: "category/:categoryUniqueId/product/:sku" [Shopping] Select Product: sku: "P1" @@ -576,6 +584,7 @@ describe('Shopping Store', () => { params: {"categoryUniqueId":"A.123.456"} queryParams: {} data: {} + url: "/category/A.123.456" path: "category/:categoryUniqueId" [ProductListing] Load More Products: id: {"type":"category","value":"A.123.456"} @@ -599,6 +608,7 @@ describe('Shopping Store', () => { params: {"searchTerm":"something"} queryParams: {} data: {} + url: "/search/something" path: "search/:searchTerm" [Shopping] Deselect Category [Shopping] Set Search Term: @@ -643,6 +653,7 @@ describe('Shopping Store', () => { params: {"categoryUniqueId":"A.123.456"} queryParams: {} data: {} + url: "/category/A.123.456" path: "category/:categoryUniqueId" [Shopping] Select Category: categoryId: "A.123.456" @@ -683,6 +694,7 @@ describe('Shopping Store', () => { params: {} queryParams: {} data: {} + url: "/compare" path: "compare" [Shopping] Deselect Category `); @@ -713,6 +725,7 @@ describe('Shopping Store', () => { params: {"categoryUniqueId":"A.123.456","sku":"P1"} queryParams: {} data: {} + url: "/category/A.123.456/product/P1" path: "category/:categoryUniqueId/product/:sku" [Viewconf Internal] Set Device Type: deviceType: "pc" @@ -773,6 +786,7 @@ describe('Shopping Store', () => { params: {"categoryUniqueId":"A.123.456"} queryParams: {} data: {} + url: "/category/A.123.456" path: "category/:categoryUniqueId" [ProductListing] Load More Products: id: {"type":"category","value":"A.123.456"} @@ -829,6 +843,7 @@ describe('Shopping Store', () => { params: {} queryParams: {} data: {} + url: "/compare" path: "compare" [Shopping] Deselect Category [Shopping] Select Product: @@ -861,6 +876,7 @@ describe('Shopping Store', () => { params: {"sku":"P1"} queryParams: {} data: {} + url: "/product/P1" path: "product/:sku" [Viewconf Internal] Set Device Type: deviceType: "pc" @@ -901,6 +917,7 @@ describe('Shopping Store', () => { params: {} queryParams: {} data: {} + url: "/compare" path: "compare" [Shopping] Select Product: sku: undefined @@ -932,6 +949,7 @@ describe('Shopping Store', () => { params: {"categoryUniqueId":"A.123.456","sku":"P3"} queryParams: {} data: {} + url: "/category/A.123.456/product/P3" path: "category/:categoryUniqueId/product/:sku" [Viewconf Internal] Set Device Type: deviceType: "pc" @@ -968,6 +986,7 @@ describe('Shopping Store', () => { params: {} queryParams: {} data: {} + url: "/error" path: "error" [Shopping] Deselect Category [Shopping] Select Product: @@ -1003,6 +1022,7 @@ describe('Shopping Store', () => { params: {"categoryUniqueId":"A.123.XXX"} queryParams: {} data: {} + url: "/category/A.123.XXX" path: "category/:categoryUniqueId" [Viewconf Internal] Set Device Type: deviceType: "pc" @@ -1022,6 +1042,7 @@ describe('Shopping Store', () => { params: {} queryParams: {} data: {} + url: "/error" path: "error" [Shopping] Deselect Category `); @@ -1049,6 +1070,7 @@ describe('Shopping Store', () => { params: {"searchTerm":"something"} queryParams: {} data: {} + url: "/search/something" path: "search/:searchTerm" [Viewconf Internal] Set Device Type: deviceType: "pc" diff --git a/src/app/core/store/user/user.effects.spec.ts b/src/app/core/store/user/user.effects.spec.ts index 34903afe58..26c024fbb8 100644 --- a/src/app/core/store/user/user.effects.spec.ts +++ b/src/app/core/store/user/user.effects.spec.ts @@ -218,7 +218,7 @@ describe('User Effects', () => { store$.dispatch(new ua.LoginUserSuccess(loginResponseData)); - actions$ = of(new RouteNavigation({ path: 'login', queryParams: {} })); + actions$ = of(new RouteNavigation({ path: 'login' })); effects.redirectAfterLogin$.subscribe(noop, fail, noop); @@ -234,7 +234,7 @@ describe('User Effects', () => { store$.dispatch(new ua.LoginUserSuccess(loginResponseData)); - actions$ = of(new RouteNavigation({ path: 'home', queryParams: {} })); + actions$ = of(new RouteNavigation({ path: 'home' })); effects.redirectAfterLogin$.subscribe(noop, fail, noop); @@ -493,7 +493,7 @@ describe('User Effects', () => { describe('resetUserError$', () => { it('should not dispatch UserErrorReset action on router navigation if error is not set', () => { - actions$ = hot('a', { a: new RouteNavigation({ path: 'any', params: {}, queryParams: {} }) }); + actions$ = hot('a', { a: new RouteNavigation({ path: 'any' }) }); expect(effects.resetUserError$).toBeObservable(cold('-')); }); @@ -501,7 +501,7 @@ describe('User Effects', () => { it('should dispatch UserErrorReset action on router navigation if error was set', () => { store$.dispatch(new ua.LoginUserFail({ error: { message: 'error' } as HttpError })); - actions$ = hot('a', { a: new RouteNavigation({ path: 'any', params: {}, queryParams: {} }) }); + actions$ = hot('a', { a: new RouteNavigation({ path: 'any' }) }); expect(effects.resetUserError$).toBeObservable(cold('a', { a: new ua.UserErrorReset() })); }); diff --git a/src/app/core/store/viewconf/viewconf.integration.spec.ts b/src/app/core/store/viewconf/viewconf.integration.spec.ts index c187fe34d9..f7f2cfb5ad 100644 --- a/src/app/core/store/viewconf/viewconf.integration.spec.ts +++ b/src/app/core/store/viewconf/viewconf.integration.spec.ts @@ -46,7 +46,7 @@ describe('Viewconf Integration', () => { expect(getWrapperClass(store$.state)).toEqual('something'); - store$.dispatch(new RouteNavigation({ path: 'any', data: {} })); + store$.dispatch(new RouteNavigation({ path: 'any' })); expect(getWrapperClass(store$.state)).toBeUndefined(); }); diff --git a/src/app/core/utils/link-parser.spec.ts b/src/app/core/utils/link-parser.spec.ts index 49c6c598e6..28f3ecc7e7 100644 --- a/src/app/core/utils/link-parser.spec.ts +++ b/src/app/core/utils/link-parser.spec.ts @@ -11,11 +11,11 @@ describe('Link Parser', () => { input: 'route://category/Home-Entertainment.SmartHome', output: '/category/Home-Entertainment.SmartHome', }, - { input: 'product://201807195@inSPIRED-inTRONICS', output: '/product/201807195' }, + { input: 'product://201807195@inSPIRED-inTRONICS', output: '/sku201807195' }, { input: 'http://google.de', output: 'http://google.de' }, { input: 'https://google.de', output: 'https://google.de' }, { input: 'page://mypage', output: '/page/mypage' }, - { input: 'category://Computers@inSPIRED-Computers', output: '/category/Computers' }, + { input: 'category://Computers@inSPIRED-Computers', output: '/catComputers' }, { input: '/product/ABC', output: '/product/ABC' }, { input: undefined, output: undefined }, ], diff --git a/src/app/core/utils/link-parser.ts b/src/app/core/utils/link-parser.ts index aad7840ba0..1663d6ca0b 100644 --- a/src/app/core/utils/link-parser.ts +++ b/src/app/core/utils/link-parser.ts @@ -9,11 +9,11 @@ export class LinkParser { case 'product': // TODO: for consistent product links it should have the default category in the route // TODO: use ProductRoutePipe - return `/product/${value}`; + return `/sku${value}`; case 'category': // TODO: the configuration parameter currently only works for first level categories // TODO: use CategoryRoutePipe - return `/category/${value}`; + return `/cat${value}`; case 'page': // CMS managed pages link return `/page/${value}`; diff --git a/src/app/extensions/quoting/models/quote-request-item/quote-request-item.model.ts b/src/app/extensions/quoting/models/quote-request-item/quote-request-item.model.ts index 986ca150ea..857c1430ad 100644 --- a/src/app/extensions/quoting/models/quote-request-item/quote-request-item.model.ts +++ b/src/app/extensions/quoting/models/quote-request-item/quote-request-item.model.ts @@ -1,5 +1,5 @@ import { Price } from 'ish-core/models/price/price.model'; -import { Product } from 'ish-core/models/product/product.model'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; export interface QuoteRequestItem { id?: string; @@ -24,5 +24,5 @@ export interface QuoteRequestItem { } export interface QuoteRequestItemView extends QuoteRequestItem { - product: Product; + product: ProductView; } diff --git a/src/app/extensions/quoting/store/quote-request/quote-request.effects.spec.ts b/src/app/extensions/quoting/store/quote-request/quote-request.effects.spec.ts index a623475101..893afb5da7 100644 --- a/src/app/extensions/quoting/store/quote-request/quote-request.effects.spec.ts +++ b/src/app/extensions/quoting/store/quote-request/quote-request.effects.spec.ts @@ -1012,7 +1012,6 @@ describe('Quote Request Effects', () => { const action = new RouteNavigation({ path: 'quote-request/:quoteRequestId', params: { quoteRequestId: 'QRID' }, - queryParams: {}, }); const expected = new quoteRequestActions.SelectQuoteRequest({ id: 'QRID' }); @@ -1021,7 +1020,7 @@ describe('Quote Request Effects', () => { }); it('should not fire SelectQuoteRequest when route /something is navigated', () => { - const action = new RouteNavigation({ path: 'something', params: {}, queryParams: {} }); + const action = new RouteNavigation({ path: 'something' }); actions$ = hot('a', { a: action }); expect(effects.routeListenerForSelectingQuote$).toBeObservable(cold('-')); diff --git a/src/app/extensions/quoting/store/quote-request/quote-request.selectors.spec.ts b/src/app/extensions/quoting/store/quote-request/quote-request.selectors.spec.ts index 9dc9dd24d5..e542cd7583 100644 --- a/src/app/extensions/quoting/store/quote-request/quote-request.selectors.spec.ts +++ b/src/app/extensions/quoting/store/quote-request/quote-request.selectors.spec.ts @@ -68,19 +68,23 @@ describe('Quote Request Selectors', () => { }); it('should set "selected" to selected quote item id and set selected quote request', () => { - const expected = { - id: 'test', - state: 'New', - items: [ - { - productSKU: 'test', - product: { sku: 'test' } as Product, - }, - ], - }; - expect(getSelectedQuoteRequestId(store$.state)).toEqual('test'); - expect(getSelectedQuoteRequestWithProducts(store$.state)).toEqual(expected); + expect(getSelectedQuoteRequestWithProducts(store$.state)).toMatchInlineSnapshot(` + Object { + "id": "test", + "items": Array [ + Object { + "product": Object { + "attributes": Array [], + "defaultCategory": [Function], + "sku": "test", + }, + "productSKU": "test", + }, + ], + "state": "New", + } + `); }); }); @@ -154,7 +158,7 @@ describe('Quote Request Selectors', () => { expect(activeQuoteRequest).toBeTruthy(); const items = activeQuoteRequest.items; expect(items).toHaveLength(1); - expect(items[0]).toHaveProperty('product', { sku: 'test' }); + expect(items[0]).toHaveProperty('product.sku', 'test'); }); }); }); diff --git a/src/app/extensions/quoting/store/quote-request/quote-request.selectors.ts b/src/app/extensions/quoting/store/quote-request/quote-request.selectors.ts index f38b606271..4eab4e3d14 100644 --- a/src/app/extensions/quoting/store/quote-request/quote-request.selectors.ts +++ b/src/app/extensions/quoting/store/quote-request/quote-request.selectors.ts @@ -1,6 +1,8 @@ import { createSelector, createSelectorFactory, defaultMemoize } from '@ngrx/store'; import { isEqual } from 'lodash-es'; +import { createProductView } from 'ish-core/models/product-view/product-view.model'; +import { getCategoryTree } from 'ish-core/store/shopping/categories'; import { getProductEntities } from 'ish-core/store/shopping/products'; import { QuoteRequestHelper } from '../../models/quote-request/quote-request.helper'; @@ -40,10 +42,11 @@ export const getQuoteRequestItems = createSelector( export const getQuoteRequestItemsWithProducts = createSelector( getQuoteRequestItems, getProductEntities, - (items, products) => + getCategoryTree, + (items, products, categoryTree) => items.map(item => ({ ...item, - product: item.productSKU ? products[item.productSKU] : undefined, + product: item.productSKU ? createProductView(products[item.productSKU], categoryTree) : undefined, })) ); diff --git a/src/app/extensions/seo/store/seo/seo.effects.ts b/src/app/extensions/seo/store/seo/seo.effects.ts index 9f04d63c56..4600d6ba8e 100644 --- a/src/app/extensions/seo/store/seo/seo.effects.ts +++ b/src/app/extensions/seo/store/seo/seo.effects.ts @@ -6,11 +6,12 @@ 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 { 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'; import { getSelectedCategory } from 'ish-core/store/shopping/categories/categories.selectors'; @@ -75,7 +76,7 @@ export class SeoEffects { @Effect() seoCategory$ = this.actions$.pipe( - ofRoute('category/:categoryUniqueId'), + ofCategoryRoute(), debounce(() => this.actions$.pipe(ofType(CategoriesActionTypes.SelectedCategoryAvailable))), switchMap(() => this.store.pipe( @@ -97,17 +98,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 new file mode 100644 index 0000000000..83e4358709 --- /dev/null +++ b/src/app/pages/app-last-routing.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { matchCategoryRoute } from 'ish-core/routing/category/category.route'; +import { matchProductRoute } from 'ish-core/routing/product/product.route'; + +const routes: Routes = [ + { + matcher: matchProductRoute, + loadChildren: () => import('./product/product-page.module').then(m => m.ProductPageModule), + }, + { + matcher: matchCategoryRoute, + loadChildren: () => import('./category/category-page.module').then(m => m.CategoryPageModule), + }, + { path: '**', redirectTo: '/error' }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], +}) +export class AppLastRoutingModule {} diff --git a/src/app/pages/app-not-found-routing.module.ts b/src/app/pages/app-not-found-routing.module.ts deleted file mode 100644 index 470d44469d..0000000000 --- a/src/app/pages/app-not-found-routing.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; - -const routes: Routes = [{ path: '**', redirectTo: '/error' }]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], -}) -export class AppNotFoundRoutingModule {} diff --git a/src/app/pages/app-routing.module.ts b/src/app/pages/app-routing.module.ts index 61739eb14d..f6dc0e1f61 100644 --- a/src/app/pages/app-routing.module.ts +++ b/src/app/pages/app-routing.module.ts @@ -28,14 +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), - }, { 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 1260107425..13fff5ab2d 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 efe8c10cc1..a983c5e4b6 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 15f1ec022a..170e778149 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 b601449cba..e686f5d793 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/pages/compare/compare-page.component.ts b/src/app/pages/compare/compare-page.component.ts index 0067d117e9..f19a5565ea 100644 --- a/src/app/pages/compare/compare-page.component.ts +++ b/src/app/pages/compare/compare-page.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; import { ShoppingFacade } from 'ish-core/facades/shopping.facade'; -import { AllProductTypes } from 'ish-core/models/product/product.model'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; @Component({ selector: 'ish-compare-page', @@ -10,7 +10,7 @@ import { AllProductTypes } from 'ish-core/models/product/product.model'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ComparePageComponent implements OnInit { - compareProducts$: Observable; + compareProducts$: Observable; compareProductsCount$: Observable; constructor(private shoppingFacade: ShoppingFacade) {} 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..7947465b0c 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 @@ -6,10 +6,12 @@ import { MockComponent, MockPipe } from 'ng-mocks'; import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { AttributeToStringPipe } from 'ish-core/models/attribute/attribute.pipe'; +import { ProductView, createProductView } from 'ish-core/models/product-view/product-view.model'; 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 { categoryTree } from 'ish-core/utils/dev/test-data-utils'; import { ProductAddToBasketComponent } from 'ish-shared/components/product/product-add-to-basket/product-add-to-basket.component'; import { ProductAttributesComponent } from 'ish-shared/components/product/product-attributes/product-attributes.component'; import { ProductIdComponent } from 'ish-shared/components/product/product-id/product-id.component'; @@ -27,8 +29,8 @@ describe('Product Compare List Component', () => { let component: ProductCompareListComponent; let element: HTMLElement; let translate: TranslateService; - let compareProduct1: Product; - let compareProduct2: Product; + let compareProduct1: ProductView; + let compareProduct2: ProductView; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ @@ -62,7 +64,7 @@ describe('Product Compare List Component', () => { translate.setDefaultLang('en'); translate.use('en'); element = fixture.nativeElement; - compareProduct1 = { sku: '111', inStock: true, availability: true } as Product; + compareProduct1 = createProductView({ sku: '111', inStock: true, availability: true } as Product, categoryTree()); compareProduct1.attributes = [ { name: 'Optical zoom', diff --git a/src/app/pages/compare/product-compare-list/product-compare-list.component.ts b/src/app/pages/compare/product-compare-list/product-compare-list.component.ts index 177d6da013..2068afc4d4 100644 --- a/src/app/pages/compare/product-compare-list/product-compare-list.component.ts +++ b/src/app/pages/compare/product-compare-list/product-compare-list.component.ts @@ -1,7 +1,8 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; import { AttributeHelper } from 'ish-core/models/attribute/attribute.helper'; -import { Product, ProductHelper } from 'ish-core/models/product/product.model'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; +import { ProductHelper } from 'ish-core/models/product/product.model'; /** * The Product Compare List Component @@ -24,7 +25,7 @@ export class ProductCompareListComponent implements OnChanges { /** * The list of products to compare */ - @Input() compareProducts: Product[] = []; + @Input() compareProducts: ProductView[] = []; /** * The maximum number of products to be compared on one page @@ -42,7 +43,7 @@ export class ProductCompareListComponent implements OnChanges { @Output() removeProductCompare = new EventEmitter(); commonAttributeNames: string[]; - visibleProducts: Product[] = []; + visibleProducts: ProductView[] = []; currentPage = 1; getAttributeByAttributeName = AttributeHelper.getAttributeByAttributeName; diff --git a/src/app/pages/product/product-page.component.spec.ts b/src/app/pages/product/product-page.component.spec.ts index 621fcc9f5c..60094aca73 100644 --- a/src/app/pages/product/product-page.component.spec.ts +++ b/src/app/pages/product/product-page.component.spec.ts @@ -6,21 +6,25 @@ 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 { Category } from 'ish-core/models/category/category.model'; import { VariationSelection } from 'ish-core/models/product-variation/variation-selection.model'; import { VariationProductView } from 'ish-core/models/product-view/product-view.model'; import { ProductRetailSet } from 'ish-core/models/product/product-retail-set.model'; 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 { LoadCategorySuccess, SelectCategory } from 'ish-core/store/shopping/categories'; import { LoadProductSuccess, LoadProductVariationsSuccess, SelectProduct } from 'ish-core/store/shopping/products'; import { shoppingReducers } from 'ish-core/store/shopping/shopping-store.module'; import { findAllIshElements } from 'ish-core/utils/dev/html-query-utils'; import { TestStore, ngrxTesting } from 'ish-core/utils/dev/ngrx-testing'; +import { categoryTree } from 'ish-core/utils/dev/test-data-utils'; import { BreadcrumbComponent } from 'ish-shared/components/common/breadcrumb/breadcrumb.component'; import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component'; import { RecentlyViewedComponent } from 'ish-shared/components/recently/recently-viewed/recently-viewed.component'; @@ -47,7 +51,7 @@ describe('Product Page Component', () => { TestBed.configureTestingModule({ imports: [ FeatureToggleModule, - RouterTestingModule.withRoutes([{ path: 'product/:sku', component: DummyComponent }]), + RouterTestingModule.withRoutes([{ path: '**', component: DummyComponent }]), ngrxTesting({ reducers: { ...coreReducers, @@ -80,6 +84,11 @@ describe('Product Page Component', () => { router = TestBed.get(Router); store$ = TestBed.get(TestStore); store$.dispatch(new ApplyConfiguration({ features: ['recently'] })); + + store$.dispatch( + new LoadCategorySuccess({ categories: categoryTree([{ uniqueId: 'A', categoryPath: ['A'] } as Category]) }) + ); + store$.dispatch(new SelectCategory({ categoryId: 'A' })); }); it('should be created', () => { @@ -144,6 +153,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; @@ -153,10 +163,12 @@ describe('Product Page Component', () => { a2: 'D', }; + fixture.detectChanges(); + component.variationSelected(selection, product); tick(500); - expect(location.path()).toEqual('/product/333'); + expect(location.path()).toMatchInlineSnapshot(`"/sku333-catA"`); })); describe('redirecting to default variation', () => { @@ -190,7 +202,7 @@ describe('Product Page Component', () => { fixture.detectChanges(); tick(500); - expect(location.path()).toMatchInlineSnapshot(`"/product/222"`); + expect(location.path()).toMatchInlineSnapshot(`"/sku222-catA"`); })); 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..52d81430ec 100644 --- a/src/app/pages/product/product-page.component.ts +++ b/src/app/pages/product/product-page.component.ts @@ -1,7 +1,7 @@ import { ApplicationRef, ChangeDetectionStrategy, Component, NgZone, OnDestroy, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { Observable, ReplaySubject, Subject, of } from 'rxjs'; -import { filter, first, map, switchMap, take, takeUntil } from 'rxjs/operators'; +import { filter, map, switchMap, take, takeUntil, withLatestFrom } from 'rxjs/operators'; import { AppFacade } from 'ish-core/facades/app.facade'; import { ShoppingFacade } from 'ish-core/facades/shopping.facade'; @@ -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,19 +137,19 @@ export class ProductPageComponent implements OnInit, OnDestroy { } redirectToVariation(variation: VariationProductView, replaceUrl = false) { - const route = variation && this.prodRoutePipe.transform(variation); - if (route) { - this.appRef.isStable - .pipe( - whenTruthy(), - first() - ) - .subscribe(() => { - this.ngZone.run(() => { - this.router.navigateByUrl(route, { replaceUrl }); - }); + this.appRef.isStable + .pipe( + filter(() => !!variation), + whenTruthy(), + take(1), + map(() => variation), + withLatestFrom(this.category$) + ) + .subscribe(([product, category]) => { + this.ngZone.run(() => { + this.router.navigateByUrl(generateProductUrl(product, category), { replaceUrl }); }); - } + }); } ngOnDestroy() { 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..acd4c18f22 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 @@ -4,8 +4,10 @@ import { TranslateModule } from '@ngx-translate/core'; import { MockComponent, MockPipe } from 'ng-mocks'; import { PricePipe } from 'ish-core/models/price/price.pipe'; +import { createProductView } from 'ish-core/models/product-view/product-view.model'; 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 { categoryTree } from 'ish-core/utils/dev/test-data-utils'; import { ProductInventoryComponent } from 'ish-shared/components/product/product-inventory/product-inventory.component'; import { ProductImageComponent } from 'ish-shell/header/product-image/product-image.component'; @@ -47,7 +49,9 @@ describe('Basket Validation Products Component', () => { }); it('should display an validation product if there is a validation product', () => { - component.items = [{ message: 'validation message', product: { sku: '4713' } as Product }]; + component.items = [ + { message: 'validation message', product: createProductView({ sku: '4713' } as Product, categoryTree()) }, + ]; fixture.detectChanges(); diff --git a/src/app/shared/components/basket/basket-validation-products/basket-validation-products.component.ts b/src/app/shared/components/basket/basket-validation-products/basket-validation-products.component.ts index b3c8c969cc..82f4137742 100644 --- a/src/app/shared/components/basket/basket-validation-products/basket-validation-products.component.ts +++ b/src/app/shared/components/basket/basket-validation-products/basket-validation-products.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { Product } from 'ish-core/models/product/product.model'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; /** * Displays the products that are not valid any more after basket validation and that has been removed from basket @@ -14,5 +14,5 @@ import { Product } from 'ish-core/models/product/product.model'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class BasketValidationProductsComponent { - @Input() items: { message: string; product: Product }[]; + @Input() items: { message: string; product: ProductView }[]; } diff --git a/src/app/shared/components/basket/basket-validation-results/basket-validation-results.component.ts b/src/app/shared/components/basket/basket-validation-results/basket-validation-results.component.ts index d72018992a..a1c1bbb40d 100644 --- a/src/app/shared/components/basket/basket-validation-results/basket-validation-results.component.ts +++ b/src/app/shared/components/basket/basket-validation-results/basket-validation-results.component.ts @@ -7,7 +7,7 @@ import { CheckoutFacade } from 'ish-core/facades/checkout.facade'; import { BasketFeedback } from 'ish-core/models/basket-feedback/basket-feedback.model'; import { BasketValidationResultType } from 'ish-core/models/basket-validation/basket-validation.model'; import { LineItemView } from 'ish-core/models/line-item/line-item.model'; -import { Product } from 'ish-core/models/product/product.model'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; /** * Displays the basket validation result messages. In case of basket adjustments removed or undeliverable items are @@ -28,7 +28,7 @@ export class BasketValidationResultsComponent implements OnInit, OnDestroy { errorMessages$: Observable; infoMessages$: Observable; undeliverableItems$: Observable; - removedItems$: Observable<{ message: string; product: Product }[]>; + removedItems$: Observable<{ message: string; product: ProductView }[]>; itemHasBeenRemoved = false; 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/common/breadcrumb/breadcrumb.component.spec.ts b/src/app/shared/components/common/breadcrumb/breadcrumb.component.spec.ts index edd873f04f..5c436aa5ce 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 0b5f5725a2..aacfd77f95 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/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/header-navigation/header-navigation.component.spec.ts b/src/app/shell/header/header-navigation/header-navigation.component.spec.ts index 387f968c93..6b42a08d3a 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 43928174ae..613cfe55ca 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'; diff --git a/src/ngrx-router/actions.ts b/src/ngrx-router/actions.ts index 841e0f61a3..ff55207713 100644 --- a/src/ngrx-router/actions.ts +++ b/src/ngrx-router/actions.ts @@ -5,5 +5,9 @@ export const ROUTER_NAVIGATION_TYPE = '[Router] Navigation'; export class RouteNavigation implements Action { readonly type = ROUTER_NAVIGATION_TYPE; // tslint:disable-next-line:no-any - constructor(public payload: { path: string; params?: any; queryParams?: any; data?: any }) {} + constructor(public payload: { path: string; url?: string; params?: any; queryParams?: any; data?: any }) { + if (!this.payload.url) { + this.payload.url = '/' + this.payload.path; + } + } } diff --git a/src/ngrx-router/effects.spec.ts b/src/ngrx-router/effects.spec.ts new file mode 100644 index 0000000000..810e8405e5 --- /dev/null +++ b/src/ngrx-router/effects.spec.ts @@ -0,0 +1,147 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { RouterEffects } from './effects'; + +describe('Effects', () => { + let effects: RouterEffects; + let router: Router; + + beforeEach(() => { + @Component({ template: 'dummy' }) + class DummyComponent {} + + const matcher = () => ({ consumed: [] }); + + TestBed.configureTestingModule({ + declarations: [DummyComponent], + imports: [ + RouterTestingModule.withRoutes([ + { path: '', redirectTo: 'home', pathMatch: 'full' }, + { path: 'home', component: DummyComponent }, + { path: 'product/:sku', component: DummyComponent }, + { path: 'data', component: DummyComponent, data: { foo: 'bar' } }, + { matcher, children: [{ path: '**', component: DummyComponent }] }, + ]), + ], + providers: [RouterEffects], + }); + + effects = TestBed.get(RouterEffects); + router = TestBed.get(Router); + }); + + it('should fire for root routes', done => { + effects.listenToRouter$.subscribe(action => { + expect(action).toMatchInlineSnapshot(` + [Router] Navigation: + params: {} + queryParams: {} + data: {} + url: "/home" + path: "home" + `); + done(); + }); + + router.navigateByUrl('/'); + }); + + it('should fire for simple routes', done => { + effects.listenToRouter$.subscribe(action => { + expect(action).toMatchInlineSnapshot(` + [Router] Navigation: + params: {} + queryParams: {} + data: {} + url: "/home" + path: "home" + `); + done(); + }); + + router.navigateByUrl('/home'); + }); + + it('should parse query params for routes', done => { + effects.listenToRouter$.subscribe(action => { + expect(action).toMatchInlineSnapshot(` + [Router] Navigation: + params: {} + queryParams: {"foo":"bar","dummy":"test"} + data: {} + url: "/home" + path: "home" + `); + done(); + }); + + router.navigateByUrl('/home?foo=bar&dummy=test'); + }); + + it('should parse params for routes', done => { + effects.listenToRouter$.subscribe(action => { + expect(action).toMatchInlineSnapshot(` + [Router] Navigation: + params: {"sku":"1234"} + queryParams: {} + data: {} + url: "/product/1234" + path: "product/:sku" + `); + done(); + }); + + router.navigateByUrl('/product/1234'); + }); + + it('should parse data for routes', done => { + effects.listenToRouter$.subscribe(action => { + expect(action).toMatchInlineSnapshot(` + [Router] Navigation: + params: {} + queryParams: {} + data: {"foo":"bar"} + url: "/data" + path: "data" + `); + done(); + }); + + router.navigateByUrl('/data'); + }); + + it('should ignore URL params for url on routes', done => { + effects.listenToRouter$.subscribe(action => { + expect(action).toMatchInlineSnapshot(` + [Router] Navigation: + params: {"lang":"de_DE"} + queryParams: {} + data: {} + url: "/any/123" + path: "**" + `); + done(); + }); + + router.navigateByUrl('/any/123;lang=de_DE'); + }); + + it('should handle both URL params and query params for url on routes', done => { + effects.listenToRouter$.subscribe(action => { + expect(action).toMatchInlineSnapshot(` + [Router] Navigation: + params: {"sku":"123","lang":"de_DE","channel":"c1"} + queryParams: {"view":"grid","page":"2"} + data: {} + url: "/product/123" + path: "product/:sku" + `); + done(); + }); + + router.navigateByUrl('/product/123;lang=de_DE;channel=c1?view=grid&page=2'); + }); +}); diff --git a/src/ngrx-router/effects.ts b/src/ngrx-router/effects.ts index c5a0c41d87..d4449c1bd9 100644 --- a/src/ngrx-router/effects.ts +++ b/src/ngrx-router/effects.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { ActivationStart, NavigationEnd, Router } from '@angular/router'; +import { ActivatedRouteSnapshot, ActivationStart, NavigationEnd, Router } from '@angular/router'; import { Effect } from '@ngrx/effects'; import { debounce, filter, map } from 'rxjs/operators'; @@ -17,17 +17,27 @@ export class RouterEffects { filter(event => event instanceof ActivationStart), debounce(() => this.navEnd$), map((event: any) => { - let route = event.snapshot; + let route = event.snapshot as ActivatedRouteSnapshot; const path: any[] = []; + const url: string[] = []; const { params, queryParams, data } = route; while (route.parent) { if (route.routeConfig && route.routeConfig.path) { path.push(route.routeConfig.path); + route.url.reverse().forEach(l => { + url.push(l.path); + }); } route = route.parent; } - const routerState = { params, queryParams, data, path: path.reverse().join('/') }; + const routerState = { + params: params || {}, + queryParams: queryParams || {}, + data: data || {}, + url: '/' + url.reverse().join('/'), + path: path.reverse().join('/'), + }; return new RouteNavigation(routerState); }) ); diff --git a/src/ngrx-router/operator.ts b/src/ngrx-router/operator.ts index 99122a00ef..612dbf82ae 100644 --- a/src/ngrx-router/operator.ts +++ b/src/ngrx-router/operator.ts @@ -9,13 +9,13 @@ export function isRoute(route?: string | string[] | RegExp) { const isRouteAction = action.type === ROUTER_NAVIGATION_TYPE; if (isRouteAction && route) { const routeAction = action as RouteNavigation; - const routePath = routeAction.payload.path; + const pathOrUrl = [routeAction.payload.path, routeAction.payload.url]; if (Array.isArray(route)) { - return route.indexOf(routePath) > -1; + return pathOrUrl.some(str => route.indexOf(str) > -1); } else if (route instanceof RegExp) { - return route.test(routePath); - } else { - return routePath === route; + return pathOrUrl.some(str => route.test(str)); + } else if (typeof route === 'string') { + return pathOrUrl.some(str => str === route); } } return isRouteAction; @@ -27,13 +27,13 @@ export function ofRoute(route?: string | string[] | RegExp): MonoTypeOperatorFun } export function mapToParam(key: string): OperatorFunction { - return map(action => action.payload.params[key]); + return map(action => action.payload.params && action.payload.params[key]); } export function mapToQueryParam(key: string): OperatorFunction { - return map(action => action.payload.queryParams[key]); + return map(action => action.payload.queryParams && action.payload.queryParams[key]); } export function mapToData(key: string): OperatorFunction { - return map(action => action.payload.data[key]); + return map(action => action.payload.data && action.payload.data[key]); } 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$",