diff --git a/e2e/cypress/integration/pages/shopping/family.page.ts b/e2e/cypress/integration/pages/shopping/family.page.ts index 41c458be29..54d50bd18e 100644 --- a/e2e/cypress/integration/pages/shopping/family.page.ts +++ b/e2e/cypress/integration/pages/shopping/family.page.ts @@ -15,6 +15,6 @@ export class FamilyPage { readonly filterNavigation = new FilterNavigationModule(); static navigateTo(categoryUniqueId: string, page?: number) { - cy.visit(`/cat${categoryUniqueId}${page ? `?page=${page}` : ''}`); + cy.visit(`/ctg${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 c8795af215..07d4f060dd 100644 --- a/e2e/cypress/integration/pages/shopping/product-detail.page.ts +++ b/e2e/cypress/integration/pages/shopping/product-detail.page.ts @@ -23,7 +23,7 @@ export class ProductDetailPage { static navigateTo(sku: string, categoryUniqueId?: string) { if (categoryUniqueId) { - cy.visit(`/sku${sku}-cat${categoryUniqueId}`); + cy.visit(`/sku${sku}-ctg${categoryUniqueId}`); } else { cy.visit(`/sku${sku}`); } diff --git a/e2e/cypress/integration/specs/system/rest-error-handling.b2c.e2e-spec.ts b/e2e/cypress/integration/specs/system/rest-error-handling.b2c.e2e-spec.ts index 19a2596d0d..ed39bd14fd 100644 --- a/e2e/cypress/integration/specs/system/rest-error-handling.b2c.e2e-spec.ts +++ b/e2e/cypress/integration/specs/system/rest-error-handling.b2c.e2e-spec.ts @@ -107,7 +107,7 @@ describe('Missing Data', () => { at(HomePage, page => page.header.gotoCategoryPage(_.catalog)); at(CategoryPage, page => page.gotoSubCategory(_.categoryid)); at(NotFoundPage); - cy.url().should('contain', `cat${_.categoryid}`); + cy.url().should('contain', `ctg${_.categoryid}`); }); }); @@ -115,7 +115,7 @@ describe('Missing Data', () => { it('should lead straight to error page', () => { FamilyPage.navigateTo('ERROAR'); at(NotFoundPage); - cy.url().should('contain', 'catERROAR'); + cy.url().should('contain', 'ctgERROAR'); }); }); }); diff --git a/e2e/cypress/integration/specs/system/seo-product-detail-page.b2c.e2e-spec.ts b/e2e/cypress/integration/specs/system/seo-product-detail-page.b2c.e2e-spec.ts index dbf3e7d1f8..9569f95792 100644 --- a/e2e/cypress/integration/specs/system/seo-product-detail-page.b2c.e2e-spec.ts +++ b/e2e/cypress/integration/specs/system/seo-product-detail-page.b2c.e2e-spec.ts @@ -11,7 +11,7 @@ describe('Page Meta', () => { at(ProductDetailPage, page => { page.metaData.check({ title: 'Google Home - Smart Home | Intershop PWA', - url: /.*\/Smart-Home\/Google-Home-sku201807171-catHome-Entertainment.SmartHome$/, + url: /.*\/smart-home\/Google-Home-sku201807171-ctgHome-Entertainment.SmartHome$/, description: 'Google Home - Hands-free help from the Google Assistant', 'og:image': /.*201807171_front.*/, }); @@ -25,7 +25,7 @@ describe('Page Meta', () => { at(FamilyPage, page => { page.metaData.check({ title: 'Smart Home - Home Entertainment | Intershop PWA', - url: /.*\/Smart-Home-catHome-Entertainment.SmartHome$/, + url: /.*\/smart-home-ctgHome-Entertainment.SmartHome$/, description: 'Smart Home, Hands-free help from the Google AssistantSmart HomeHome Entertainment', }); }); diff --git a/e2e/cypress/integration/specs/system/seo-search-page.b2c.e2e-spec.ts b/e2e/cypress/integration/specs/system/seo-search-page.b2c.e2e-spec.ts index 0fdbc749cc..007e7f4f2b 100644 --- a/e2e/cypress/integration/specs/system/seo-search-page.b2c.e2e-spec.ts +++ b/e2e/cypress/integration/specs/system/seo-search-page.b2c.e2e-spec.ts @@ -24,7 +24,7 @@ describe('Page Meta', () => { at(ProductDetailPage, page => { page.metaData.check({ title: 'Kodak Slice - Digital Cameras | Intershop PWA', - url: /.*\/Digital-Cameras\/Kodak-Slice-sku3957284-catCameras-Camcorders.575$/, + url: /.*\/digital-cameras\/Kodak-Slice-sku3957284-ctgCameras-Camcorders.575$/, description: 'Kodak Slice - Slice - 14MP, 5x optisch, 16:9, nickel', 'og:image': /.*3957284-5640.jpg.*/, }); diff --git a/e2e/test-universal.sh b/e2e/test-universal.sh index 3b5218b78f..6774eb540e 100644 --- a/e2e/test-universal.sh +++ b/e2e/test-universal.sh @@ -20,19 +20,19 @@ echo "Waiting for $waitOn" npx wait-on "$waitOn" universalTest 1 "${PWA_BASE_URL}/" "router-outlet>Notebooks and PCs" -universalTest 6 "${PWA_BASE_URL}/catComputers.1835" "

PCs

" -universalTest 7 "${PWA_BASE_URL}/catComputers.1835.151" "add-to-compare" +universalTest 5 "${PWA_BASE_URL}/computers/notebooks-and-pcs-ctgComputers.1835" "

Notebooks and PCs

" +universalTest 6 "${PWA_BASE_URL}/computers/notebooks-and-pcs-ctgComputers.1835" "

PCs

" +universalTest 7 "${PWA_BASE_URL}/computers/notebooks-and-pcs/notebooks-ctgComputers.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" "" universalTest 12 "${PWA_BASE_URL}/home" "inTRONICS Home | Intershop PWA" -universalTest 14 "${PWA_BASE_URL}/sku6997041" "" +universalTest 14 "${PWA_BASE_URL}/sku6997041" "" universalTest 15 "${PWA_BASE_URL}/sku6997041" "]*6997041" universalTest 16 "${PWA_BASE_URL}/sku6997041" "Asus Eee PC 1008P .Karim Rashid. [^>]* | Intershop PWA" universalTest 17 "${PWA_BASE_URL}/home;device=tablet" "class=.header container tablet" diff --git a/src/app/core/facades/product-context.facade.spec.ts b/src/app/core/facades/product-context.facade.spec.ts index dbf9805e08..98f800ebd5 100644 --- a/src/app/core/facades/product-context.facade.spec.ts +++ b/src/app/core/facades/product-context.facade.spec.ts @@ -395,7 +395,7 @@ describe('Product Context Facade', () => { }); it('should calculate the url property of the product with default category', () => { - expect(context.get('productURL')).toMatchInlineSnapshot(`"//sku123-catABC"`); + expect(context.get('productURL')).toMatchInlineSnapshot(`"//sku123-ctgABC"`); }); }); @@ -416,7 +416,7 @@ describe('Product Context Facade', () => { }); it('should calculate the url property of the product with context category', () => { - expect(context.get('productURL')).toMatchInlineSnapshot(`"//sku123-catASDF"`); + expect(context.get('productURL')).toMatchInlineSnapshot(`"//sku123-ctgASDF"`); }); }); @@ -438,7 +438,7 @@ describe('Product Context Facade', () => { }); it('should calculate the url property of the product with context category', () => { - expect(context.get('productURL')).toMatchInlineSnapshot(`"//sku123-catASDF"`); + expect(context.get('productURL')).toMatchInlineSnapshot(`"//sku123-ctgASDF"`); }); }); }); diff --git a/src/app/core/models/category-view/category-view.model.ts b/src/app/core/models/category-view/category-view.model.ts index a911a4fc0d..50a4544341 100644 --- a/src/app/core/models/category-view/category-view.model.ts +++ b/src/app/core/models/category-view/category-view.model.ts @@ -7,6 +7,7 @@ import { Category } from 'ish-core/models/category/category.model'; export interface CategoryView extends Category { children: string[]; hasChildren: boolean; + pathElements: Category[]; } export function createCategoryView(tree: CategoryTree, uniqueId: string): CategoryView { @@ -21,6 +22,9 @@ export function createCategoryView(tree: CategoryTree, uniqueId: string): Catego ...tree.nodes[translateRef(tree, uniqueId)], children: tree.edges[translateRef(tree, uniqueId)] || [], hasChildren: !!tree.edges[translateRef(tree, uniqueId)] && !!tree.edges[translateRef(tree, uniqueId)].length, + pathElements: (tree.nodes[translateRef(tree, uniqueId)]?.categoryPath || []) + .map(path => tree.nodes[translateRef(tree, path)]) + .filter(x => !!x), }; } diff --git a/src/app/core/routing/category/category.route.spec.ts b/src/app/core/routing/category/category.route.spec.ts index 4bff3f5657..d774f914bd 100644 --- a/src/app/core/routing/category/category.route.spec.ts +++ b/src/app/core/routing/category/category.route.spec.ts @@ -68,10 +68,10 @@ describe('Category Route', () => { const category = createCategoryView(categoryTree([{ ...specials, name: undefined }]), specials.uniqueId); it('should be created', () => { - expect(generateCategoryUrl(category)).toMatchInlineSnapshot(`"/catSpecials"`); + expect(generateCategoryUrl(category)).toMatchInlineSnapshot(`"/ctgSpecials"`); }); - it('should not be a match for matcher', () => { + it('should be a match for matcher', () => { expect(matchCategoryRoute(wrap(generateCategoryUrl(category)))).toMatchInlineSnapshot(` Object { "categoryUniqueId": "Specials", @@ -84,10 +84,10 @@ describe('Category Route', () => { const category = createCategoryView(categoryTree([specials]), specials.uniqueId); it('should be created', () => { - expect(generateCategoryUrl(category)).toMatchInlineSnapshot(`"/Spezielles-1/2-Preis-Aktion-mehr-catSpecials"`); + expect(generateCategoryUrl(category)).toMatchInlineSnapshot(`"/spezielles-1/2-preis-aktion-mehr-ctgSpecials"`); }); - it('should not be a match for matcher', () => { + it('should be a match for matcher', () => { expect(matchCategoryRoute(wrap(generateCategoryUrl(category)))).toMatchInlineSnapshot(` Object { "categoryUniqueId": "Specials", @@ -100,10 +100,12 @@ describe('Category Route', () => { const category = createCategoryView(categoryTree([specials, topSeller, limitedOffer]), limitedOffer.uniqueId); it('should be created', () => { - expect(generateCategoryUrl(category)).toMatchInlineSnapshot(`"/Black-Friday-catSpecials.TopSeller.LimitedOffer"`); + expect(generateCategoryUrl(category)).toMatchInlineSnapshot( + `"/spezielles-1/2-preis-aktion-mehr/angebote/black-friday-ctgSpecials.TopSeller.LimitedOffer"` + ); }); - it('should not be a match for matcher', () => { + it('should be a match for matcher', () => { expect(matchCategoryRoute(wrap(generateCategoryUrl(category)))).toMatchInlineSnapshot(` Object { "categoryUniqueId": "Specials.TopSeller.LimitedOffer", @@ -146,12 +148,12 @@ describe('Category Route', () => { describe('generateLocalizedCategorySlug', () => { it('should generate slug for top level category', () => { const category = createCategoryView(categoryTree([specials]), specials.uniqueId); - expect(generateLocalizedCategorySlug(category)).toMatchInlineSnapshot(`"Spezielles-1/2-Preis-Aktion-mehr"`); + expect(generateLocalizedCategorySlug(category)).toMatchInlineSnapshot(`"spezielles-1/2-preis-aktion-mehr"`); }); it('should generate slug for deep category', () => { const category = createCategoryView(categoryTree([specials, topSeller, limitedOffer]), limitedOffer.uniqueId); - expect(generateLocalizedCategorySlug(category)).toMatchInlineSnapshot(`"Black-Friday"`); + expect(generateLocalizedCategorySlug(category)).toMatchInlineSnapshot(`"black-friday"`); }); it('should return empty string when category is unavailable', () => { diff --git a/src/app/core/routing/category/category.route.ts b/src/app/core/routing/category/category.route.ts index a8415ab47e..c2e285dba4 100644 --- a/src/app/core/routing/category/category.route.ts +++ b/src/app/core/routing/category/category.route.ts @@ -2,31 +2,43 @@ import { UrlMatchResult, UrlSegment } from '@angular/router'; import { MonoTypeOperatorFunction } from 'rxjs'; import { filter } from 'rxjs/operators'; +import { CategoryView } from 'ish-core/models/category-view/category-view.model'; import { Category } from 'ish-core/models/category/category.model'; import { CoreState } from 'ish-core/store/core/core-store'; import { selectRouteParam, selectRouteParamAorB } from 'ish-core/store/core/router'; -import { reservedCharactersRegEx } from 'ish-core/utils/routing'; +import { sanitizeSlugData } from 'ish-core/utils/routing'; +/** + * generate a localized category slug + * + * @param category category element for slug + * @returns localized, formatted category slug + */ export function generateLocalizedCategorySlug(category: Category) { - return ( - category?.name - ?.replace(reservedCharactersRegEx, '-') - .replace(/-+/g, '-') - .replace(/-+$/, '') - .replace('-cat', '-Cat') || '' - ); + return sanitizeSlugData(category?.name); } -const categoryRouteFormat = /^\/(?!category|categoryref\/.*$)(.*-)?cat(.*)$/; +// matcher to check if a given url is a category route +const categoryRouteFormat = /^\/(?!category|categoryref\/.*$)(.*?)-?ctg(.*)$/; +/** + * check if given url is a category route + * + * @param segments current url segments + * @returns match result if given url is a category route or not + */ export function matchCategoryRoute(segments: UrlSegment[]): UrlMatchResult { // compatibility to old routes if (segments && segments.length === 2 && (segments[0].path === 'category' || segments[0].path === 'categoryref')) { return { consumed: [] }; } + // generate complete url path const url = `/${segments.map(s => s.path).join('/')}`; + + // check that complete url path is a category route if (categoryRouteFormat.test(url)) { + // select categoryUniqueId to render a category component const match = categoryRouteFormat.exec(url); const posParams: { [id: string]: UrlSegment } = {}; if (match[2]) { @@ -40,19 +52,29 @@ export function matchCategoryRoute(segments: UrlSegment[]): UrlMatchResult { return; } -export function generateCategoryUrl(category: Category): string { +/** + * generate a localized category url from a category view + * + * @param category category view + * @returns localized category url + */ +export function generateCategoryUrl(category: CategoryView): string { if (!category) { return '/'; } - let route = '/'; - route += generateLocalizedCategorySlug(category); + // generate for each path element from the given category view a category slug and join them together to a complete route + let route = `/${category.pathElements + ?.filter(x => !!x) + .map(el => generateLocalizedCategorySlug(el)) + .join('/')}`; + // add to category route the category identifier if (route !== '/') { route += '-'; } - route += `cat${category.uniqueId}`; + route += `ctg${category.uniqueId}`; return route; } diff --git a/src/app/core/routing/product/product.route.spec.ts b/src/app/core/routing/product/product.route.spec.ts index 3487d4fe7a..d09839c430 100644 --- a/src/app/core/routing/product/product.route.spec.ts +++ b/src/app/core/routing/product/product.route.spec.ts @@ -123,7 +123,7 @@ describe('Product Route', () => { const product = createProductView({ sku: 'A' } as Product, specials); it('should be created', () => { - expect(generateProductUrl(product, category)).toMatchInlineSnapshot(`"/Spezielles/skuA-catSpecials"`); + expect(generateProductUrl(product, category)).toMatchInlineSnapshot(`"/spezielles/skuA-ctgSpecials"`); }); it('should be a match for matcher', () => { @@ -141,7 +141,7 @@ describe('Product Route', () => { it('should be created', () => { expect(generateProductUrl(product, category)).toMatchInlineSnapshot( - `"/Spezielles/Das-neue-Surface-Pro-7-skuA-catSpecials"` + `"/spezielles/Das-neue-Surface-Pro-7-skuA-ctgSpecials"` ); }); @@ -161,7 +161,7 @@ describe('Product Route', () => { const product = createProductView({ sku: 'A' } as Product, specials); it('should be created', () => { - expect(generateProductUrl(product)).toMatchInlineSnapshot(`"/Spezielles/skuA-catSpecials"`); + expect(generateProductUrl(product)).toMatchInlineSnapshot(`"/spezielles/skuA-ctgSpecials"`); }); it('should be a match for matcher', () => { @@ -179,7 +179,7 @@ describe('Product Route', () => { it('should be created', () => { expect(generateProductUrl(product)).toMatchInlineSnapshot( - `"/Spezielles/Das-neue-Surface-Pro-7-skuA-catSpecials"` + `"/spezielles/Das-neue-Surface-Pro-7-skuA-ctgSpecials"` ); }); @@ -205,7 +205,7 @@ describe('Product Route', () => { it('should be created', () => { expect(generateProductUrl(product, category)).toMatchInlineSnapshot( - `"/Black-Friday/skuA-catSpecials.TopSeller.LimitedOffer"` + `"/black-friday/skuA-ctgSpecials.TopSeller.LimitedOffer"` ); }); @@ -224,7 +224,7 @@ describe('Product Route', () => { it('should be created', () => { expect(generateProductUrl(product, category)).toMatchInlineSnapshot( - `"/Black-Friday/Das-neue-Surface-Pro-7-skuA-catSpecials.TopSeller.LimitedOffer"` + `"/black-friday/Das-neue-Surface-Pro-7-skuA-ctgSpecials.TopSeller.LimitedOffer"` ); }); @@ -245,7 +245,7 @@ describe('Product Route', () => { it('should be created', () => { expect(generateProductUrl(product)).toMatchInlineSnapshot( - `"/Black-Friday/skuA-catSpecials.TopSeller.LimitedOffer"` + `"/black-friday/skuA-ctgSpecials.TopSeller.LimitedOffer"` ); }); @@ -264,7 +264,7 @@ describe('Product Route', () => { it('should be created', () => { expect(generateProductUrl(product)).toMatchInlineSnapshot( - `"/Black-Friday/Das-neue-Surface-Pro-7-skuA-catSpecials.TopSeller.LimitedOffer"` + `"/black-friday/Das-neue-Surface-Pro-7-skuA-ctgSpecials.TopSeller.LimitedOffer"` ); }); diff --git a/src/app/core/routing/product/product.route.ts b/src/app/core/routing/product/product.route.ts index 53764286eb..83a8b52ec8 100644 --- a/src/app/core/routing/product/product.route.ts +++ b/src/app/core/routing/product/product.route.ts @@ -30,7 +30,8 @@ function generateProductSlug(product: ProductView) { return slug.replace('-cat', '-Cat'); } -const productRouteFormat = new RegExp('/(?!cat)((?!.*-cat.*-sku).*-)?sku(.*?)(-cat(.*))?$'); +// matcher to check if a given url is a product url +const productRouteFormat = /\/(?!ctg)(?!.*-ctg.*-sku)(.*?)-?sku(.*?)(-ctg(.*))?$/; export function matchProductRoute(segments: UrlSegment[]): UrlMatchResult { // compatibility to old routes @@ -78,7 +79,7 @@ export function generateProductUrl(product: ProductView, category?: Category): s route += `sku${product.sku}`; if (contextCategory) { - route += `-cat${contextCategory.uniqueId}`; + route += `-ctg${contextCategory.uniqueId}`; } return route; diff --git a/src/app/core/routing/routing.integration.spec.ts b/src/app/core/routing/routing.integration.spec.ts index 387953b8d3..7d8e4d37de 100644 --- a/src/app/core/routing/routing.integration.spec.ts +++ b/src/app/core/routing/routing.integration.spec.ts @@ -65,9 +65,9 @@ describe('Routing Integration', () => { }); it('should land at the product page for simple syntax with sku and categoryUniqueId only', async () => { - await router.navigateByUrl('/sku12345-catCAT'); + await router.navigateByUrl('/sku12345-ctgCAT'); - expect(location.path()).toEqual('/sku12345-catCAT'); + expect(location.path()).toEqual('/sku12345-ctgCAT'); expect(selectRouteData('page')(store.state)).toEqual('product'); expect(selectRouteParam('sku')(store.state)).toEqual('12345'); @@ -75,9 +75,9 @@ describe('Routing Integration', () => { }); it('should land at the product page for syntax with slug, sku and categoryUniqueId', async () => { - await router.navigateByUrl('/fancy-category/fancy-product-sku12345-catCAT'); + await router.navigateByUrl('/fancy-category/fancy-product-sku12345-ctgCAT'); - expect(location.path()).toEqual('/fancy-category/fancy-product-sku12345-catCAT'); + expect(location.path()).toEqual('/fancy-category/fancy-product-sku12345-ctgCAT'); expect(selectRouteData('page')(store.state)).toEqual('product'); expect(selectRouteParam('sku')(store.state)).toEqual('12345'); @@ -87,9 +87,9 @@ describe('Routing Integration', () => { describe('navigating to a category page', () => { it('should land at the category page for simple syntax with categoryUniqueId only', async () => { - await router.navigateByUrl('/catCAT'); + await router.navigateByUrl('/ctgCAT'); - expect(location.path()).toEqual('/catCAT'); + expect(location.path()).toEqual('/ctgCAT'); expect(selectRouteData('page')(store.state)).toEqual('category'); expect(selectRouteParam('sku')(store.state)).toBeUndefined(); @@ -97,9 +97,9 @@ describe('Routing Integration', () => { }); it('should land at the category page for syntax with slug and categoryUniqueId', async () => { - await router.navigateByUrl('/fancy-category-catCAT'); + await router.navigateByUrl('/fancy-category-ctgCAT'); - expect(location.path()).toEqual('/fancy-category-catCAT'); + expect(location.path()).toEqual('/fancy-category-ctgCAT'); expect(selectRouteData('page')(store.state)).toEqual('category'); expect(selectRouteParam('sku')(store.state)).toBeUndefined(); @@ -109,9 +109,9 @@ describe('Routing Integration', () => { describe('special case with tokens in URL', () => { it('should navigate to the correct product page when sku contains "sku"', async () => { - await router.navigateByUrl('/Acer/Glaskugel-skuglaskugel_1-catHome'); + await router.navigateByUrl('/acer/glaSkugel-skuglaskugel_1-ctgHome'); - expect(location.path()).toEqual('/Acer/Glaskugel-skuglaskugel_1-catHome'); + expect(location.path()).toEqual('/acer/glaSkugel-skuglaskugel_1-ctgHome'); expect(selectRouteData('page')(store.state)).toEqual('product'); expect(selectRouteParam('sku')(store.state)).toEqual('glaskugel_1'); @@ -119,33 +119,33 @@ describe('Routing Integration', () => { }); it('should navigate to the correct product page when category contains "cat"', async () => { - await router.navigateByUrl('/Furniture/Cat-Tree-skutree_1-catTrees-for-cats'); + await router.navigateByUrl('/furniture/cat-tree-skutree_1-ctgtrees-for-cats'); - expect(location.path()).toEqual('/Furniture/Cat-Tree-skutree_1-catTrees-for-cats'); + expect(location.path()).toEqual('/furniture/cat-tree-skutree_1-ctgtrees-for-cats'); expect(selectRouteData('page')(store.state)).toEqual('product'); expect(selectRouteParam('sku')(store.state)).toEqual('tree_1'); - expect(selectRouteParam('categoryUniqueId')(store.state)).toEqual('Trees-for-cats'); + expect(selectRouteParam('categoryUniqueId')(store.state)).toEqual('trees-for-cats'); }); it('should navigate to the correct product page when sku contains "cat"', async () => { - await router.navigateByUrl('/Furniture/Cat-Tree-skucat-tree_1-catTrees-for-cats'); + await router.navigateByUrl('/furniture/cat-tree-skucat-tree_1-ctgtrees-for-cats'); - expect(location.path()).toEqual('/Furniture/Cat-Tree-skucat-tree_1-catTrees-for-cats'); + expect(location.path()).toEqual('/furniture/cat-tree-skucat-tree_1-ctgtrees-for-cats'); expect(selectRouteData('page')(store.state)).toEqual('product'); expect(selectRouteParam('sku')(store.state)).toEqual('cat-tree_1'); - expect(selectRouteParam('categoryUniqueId')(store.state)).toEqual('Trees-for-cats'); + expect(selectRouteParam('categoryUniqueId')(store.state)).toEqual('trees-for-cats'); }); it('should navigate to the correct product page when sku contains "cat" with simple route', async () => { - await router.navigateByUrl('/skucat-tree_1-catTrees-for-cats'); + await router.navigateByUrl('/skucat-tree_1-ctgtrees-for-cats'); - expect(location.path()).toEqual('/skucat-tree_1-catTrees-for-cats'); + expect(location.path()).toEqual('/skucat-tree_1-ctgtrees-for-cats'); expect(selectRouteData('page')(store.state)).toEqual('product'); expect(selectRouteParam('sku')(store.state)).toEqual('cat-tree_1'); - expect(selectRouteParam('categoryUniqueId')(store.state)).toEqual('Trees-for-cats'); + expect(selectRouteParam('categoryUniqueId')(store.state)).toEqual('trees-for-cats'); }); it('should navigate to the correct product page when sku contains "cat" with simple route by sku only', async () => { @@ -159,23 +159,23 @@ describe('Routing Integration', () => { }); it('should navigate to the correct category page when uniqueId contains "-sku"', async () => { - await router.navigateByUrl('/Cult-Equipment-catCrosses-and-skulls'); + await router.navigateByUrl('/cult-equipment-ctgcrosses-and-skulls'); - expect(location.path()).toEqual('/Cult-Equipment-catCrosses-and-skulls'); + expect(location.path()).toEqual('/cult-equipment-ctgcrosses-and-skulls'); expect(selectRouteData('page')(store.state)).toEqual('category'); expect(selectRouteParam('sku')(store.state)).toBeUndefined(); - expect(selectRouteParam('categoryUniqueId')(store.state)).toEqual('Crosses-and-skulls'); + expect(selectRouteParam('categoryUniqueId')(store.state)).toEqual('crosses-and-skulls'); }); it('should navigate to the correct category page when uniqueId contains "-sku" with simple route', async () => { - await router.navigateByUrl('/catCrosses-and-skulls'); + await router.navigateByUrl('/ctgcrosses-and-skulls'); - expect(location.path()).toEqual('/catCrosses-and-skulls'); + expect(location.path()).toEqual('/ctgcrosses-and-skulls'); expect(selectRouteData('page')(store.state)).toEqual('category'); expect(selectRouteParam('sku')(store.state)).toBeUndefined(); - expect(selectRouteParam('categoryUniqueId')(store.state)).toEqual('Crosses-and-skulls'); + expect(selectRouteParam('categoryUniqueId')(store.state)).toEqual('crosses-and-skulls'); }); }); }); diff --git a/src/app/core/store/shopping/categories/categories.selectors.spec.ts b/src/app/core/store/shopping/categories/categories.selectors.spec.ts index 482c54cb82..f3031f96d9 100644 --- a/src/app/core/store/shopping/categories/categories.selectors.spec.ts +++ b/src/app/core/store/shopping/categories/categories.selectors.spec.ts @@ -160,7 +160,7 @@ describe('Categories Selectors', () => { expect(getBreadcrumbForCategoryPage(store$.state)).toMatchInlineSnapshot(` Array [ Object { - "link": "/nA-catA", + "link": "/na-ctgA", "text": "nA", }, Object { @@ -193,13 +193,13 @@ describe('Categories Selectors', () => { "hasChildren": true, "name": "name_A", "uniqueId": "A", - "url": "/name_A-catA", + "url": "/name_a-ctgA", }, Object { "hasChildren": false, "name": "name_B", "uniqueId": "B", - "url": "/name_B-catB", + "url": "/name_b-ctgB", }, ] `); @@ -212,13 +212,13 @@ describe('Categories Selectors', () => { "hasChildren": true, "name": "name_A.1", "uniqueId": "A.1", - "url": "/name_A.1-catA.1", + "url": "/name_a/name_a.1-ctgA.1", }, Object { "hasChildren": false, "name": "name_A.2", "uniqueId": "A.2", - "url": "/name_A.2-catA.2", + "url": "/name_a/name_a.2-ctgA.2", }, ] `); @@ -231,13 +231,13 @@ describe('Categories Selectors', () => { "hasChildren": false, "name": "name_A.1.a", "uniqueId": "A.1.a", - "url": "/name_A.1.a-catA.1.a", + "url": "/name_a/name_a.1/name_a.1.a-ctgA.1.a", }, Object { "hasChildren": false, "name": "name_A.1.b", "uniqueId": "A.1.b", - "url": "/name_A.1.b-catA.1.b", + "url": "/name_a/name_a.1/name_a.1.b-ctgA.1.b", }, ] `); diff --git a/src/app/core/store/shopping/categories/categories.selectors.ts b/src/app/core/store/shopping/categories/categories.selectors.ts index 61c45cb6f6..5b47eb2537 100644 --- a/src/app/core/store/shopping/categories/categories.selectors.ts +++ b/src/app/core/store/shopping/categories/categories.selectors.ts @@ -1,11 +1,10 @@ -import { Dictionary } from '@ngrx/entity'; import { createSelector, createSelectorFactory, defaultMemoize, resultMemoize } from '@ngrx/store'; import { isEqual } from 'lodash-es'; import { BreadcrumbItem } from 'ish-core/models/breadcrumb-item/breadcrumb-item.interface'; import { CategoryTree, CategoryTreeHelper } from 'ish-core/models/category-tree/category-tree.model'; import { CategoryView, createCategoryView } from 'ish-core/models/category-view/category-view.model'; -import { Category, CategoryHelper } from 'ish-core/models/category/category.model'; +import { CategoryHelper } from 'ish-core/models/category/category.model'; import { NavigationCategory } from 'ish-core/models/navigation-category/navigation-category.model'; import { generateCategoryUrl } from 'ish-core/routing/category/category.route'; import { selectRouteParamAorB } from 'ish-core/store/core/router'; @@ -22,15 +21,10 @@ export const getCategoryEntities = createSelector(getCategoryTree, tree => tree. export const getCategoryRefs = createSelector(getCategoryTree, tree => tree.categoryRefs); -const getCategorySubTree = (uniqueId: string) => - createSelectorFactory(projector => - defaultMemoize(projector, CategoryTreeHelper.equals, CategoryTreeHelper.equals) - )(getCategoryTree, (tree: CategoryTree) => CategoryTreeHelper.subTree(tree, uniqueId)); - export const getCategory = (uniqueId: string) => createSelectorFactory(projector => defaultMemoize(projector, CategoryTreeHelper.equals, isEqual) - )(getCategorySubTree(uniqueId), (tree: CategoryTree) => createCategoryView(tree, uniqueId)); + )(getCategoryTree, (tree: CategoryTree) => createCategoryView(tree, uniqueId)); export const getCategoryIdByRefId = (categoryRefId: string) => createSelectorFactory(projector => defaultMemoize(projector, isEqual))( @@ -47,10 +41,10 @@ export const getSelectedCategory = createSelectorFactory(p export const getBreadcrumbForCategoryPage = createSelectorFactory(projector => resultMemoize(projector, isEqual) -)(getSelectedCategory, getCategoryEntities, (category: CategoryView, entities: Dictionary) => +)(getSelectedCategory, getCategoryTree, (category: CategoryView, tree: CategoryTree) => CategoryHelper.isCategoryCompletelyLoaded(category) ? (category.categoryPath || []) - .map(id => entities[id]) + .map(id => createCategoryView(tree, id)) .filter(x => !!x) .map((cat, idx, arr) => ({ text: cat.name, @@ -59,12 +53,13 @@ export const getBreadcrumbForCategoryPage = createSelectorFactory defaultMemoize(projector, CategoryTreeHelper.equals, isEqual) )(getCategoryTree, (tree: CategoryTree): NavigationCategory[] => { if (!uniqueId) { - return tree.rootIds.map(mapNavigationCategoryFromId.bind(tree)); + return tree.rootIds.map(id => mapNavigationCategoryFromId(id, tree)); } const subTree = CategoryTreeHelper.subTree(tree, uniqueId); - return subTree.edges[uniqueId] ? subTree.edges[uniqueId].map(mapNavigationCategoryFromId.bind(subTree)) : []; + return subTree.edges[uniqueId] + ? subTree.edges[uniqueId].map(id => mapNavigationCategoryFromId(id, tree, subTree)) + : []; }); diff --git a/src/app/core/store/shopping/products/products.selectors.spec.ts b/src/app/core/store/shopping/products/products.selectors.spec.ts index 561ddcc01c..d65c265d83 100644 --- a/src/app/core/store/shopping/products/products.selectors.spec.ts +++ b/src/app/core/store/shopping/products/products.selectors.spec.ts @@ -186,7 +186,7 @@ describe('Products Selectors', () => { expect(getBreadcrumbForProductPage(store$.state)).toMatchInlineSnapshot(` Array [ Object { - "link": "/nA-catA", + "link": "/na-ctgA", "text": "nA", }, Object { @@ -208,7 +208,7 @@ describe('Products Selectors', () => { expect(getBreadcrumbForProductPage(store$.state)).toMatchInlineSnapshot(` Array [ Object { - "link": "/nB-catB", + "link": "/nb-ctgB", "text": "nB", }, Object { @@ -231,7 +231,7 @@ describe('Products Selectors', () => { expect(getBreadcrumbForProductPage(store$.state)).toMatchInlineSnapshot(` Array [ Object { - "link": "/nB-catB", + "link": "/nb-ctgB", "text": "nB", }, Object { diff --git a/src/app/core/store/shopping/products/products.selectors.ts b/src/app/core/store/shopping/products/products.selectors.ts index 40f2fe3305..fdffbdec0d 100644 --- a/src/app/core/store/shopping/products/products.selectors.ts +++ b/src/app/core/store/shopping/products/products.selectors.ts @@ -4,7 +4,8 @@ import { isEqual } from 'lodash-es'; import { identity } from 'rxjs'; import { BreadcrumbItem } from 'ish-core/models/breadcrumb-item/breadcrumb-item.interface'; -import { CategoryView } from 'ish-core/models/category-view/category-view.model'; +import { CategoryTree } from 'ish-core/models/category-tree/category-tree.model'; +import { CategoryView, createCategoryView } from 'ish-core/models/category-view/category-view.model'; import { Category } from 'ish-core/models/category/category.model'; import { ProductVariationHelper } from 'ish-core/models/product-variation/product-variation.helper'; import { @@ -22,7 +23,7 @@ import { } from 'ish-core/models/product/product.model'; import { generateCategoryUrl } from 'ish-core/routing/category/category.route'; import { selectRouteParam } from 'ish-core/store/core/router'; -import { getCategoryEntities, getSelectedCategory } from 'ish-core/store/shopping/categories'; +import { getCategoryEntities, getCategoryTree, getSelectedCategory } from 'ish-core/store/shopping/categories'; import { getAvailableFilter } from 'ish-core/store/shopping/filter'; import { getShoppingState } from 'ish-core/store/shopping/shopping-store'; @@ -126,11 +127,11 @@ export const getBreadcrumbForProductPage = createSelectorFactory): BreadcrumbItem[] => + getCategoryTree, + (product: ProductView, category: CategoryView, tree: CategoryTree): BreadcrumbItem[] => ProductHelper.isSufficientlyLoaded(product, ProductCompletenessLevel.Detail) ? (category?.categoryPath || product.defaultCategory?.categoryPath || []) - .map(id => entities[id]) + .map(id => createCategoryView(tree, id)) .filter(x => !!x) .map(cat => ({ text: cat.name, diff --git a/src/app/core/store/shopping/shopping-store.spec.ts b/src/app/core/store/shopping/shopping-store.spec.ts index 15b54f0a2d..0495c8aa54 100644 --- a/src/app/core/store/shopping/shopping-store.spec.ts +++ b/src/app/core/store/shopping/shopping-store.spec.ts @@ -248,10 +248,10 @@ describe('Shopping Store', () => { [Categories API] Load Category Success: categories: tree(A,A.123,A.123.456) [Viewconf Internal] Set Breadcrumb Data: - breadcrumbData: [{"text":"nA","link":"/nA-catA"},{"text":"nA123"}] + breadcrumbData: [{"text":"nA","link":"/na-ctgA"},{"text":"nA123"}] @ngrx/router-store/navigated: /category/A.123 [Viewconf Internal] Set Breadcrumb Data: - breadcrumbData: [{"text":"nA","link":"/nA-catA"},{"text":"nA123"}] + breadcrumbData: [{"text":"nA","link":"/na-ctgA"},{"text":"nA123"}] `); })); }); @@ -369,7 +369,7 @@ describe('Shopping Store', () => { categories: tree(A,A.123,A.123.456) @ngrx/router-store/navigated: /category/A.123 [Viewconf Internal] Set Breadcrumb Data: - breadcrumbData: [{"text":"nA","link":"/nA-catA"},{"text":"nA123"}] + breadcrumbData: [{"text":"nA","link":"/na-ctgA"},{"text":"nA123"}] `); })); @@ -436,7 +436,7 @@ describe('Shopping Store', () => { [Product Listing] Load More Products: id: {"type":"category","value":"A.123.456"} [Viewconf Internal] Set Breadcrumb Data: - breadcrumbData: [{"text":"nA","link":"/nA-catA"},{"text":"nA123","link":"/nA... + breadcrumbData: [{"text":"nA","link":"/na-ctgA"},{"text":"nA123","link":"/na... [Product Listing Internal] Load More Products For Params: id: {"type":"category","value":"A.123.456"} filters: undefined @@ -495,12 +495,12 @@ describe('Shopping Store', () => { [Product Listing] Load More Products: id: {"type":"category","value":"A.123.456"} [Viewconf Internal] Set Breadcrumb Data: - breadcrumbData: [{"text":"nA","link":"/nA-catA"},{"text":"nA123","link":"/nA... + breadcrumbData: [{"text":"nA","link":"/na-ctgA"},{"text":"nA123","link":"/na... @ngrx/router-store/navigated: /category/A.123.456 [Product Listing] Load More Products: id: {"type":"category","value":"A.123.456"} [Viewconf Internal] Set Breadcrumb Data: - breadcrumbData: [{"text":"nA","link":"/nA-catA"},{"text":"nA123","link":"/nA... + breadcrumbData: [{"text":"nA","link":"/na-ctgA"},{"text":"nA123","link":"/na... `); })); }); @@ -561,7 +561,7 @@ describe('Shopping Store', () => { [Product Listing] Load More Products: id: {"type":"category","value":"A.123.456"} [Viewconf Internal] Set Breadcrumb Data: - breadcrumbData: [{"text":"nA","link":"/nA-catA"},{"text":"nA123","link":"/nA... + breadcrumbData: [{"text":"nA","link":"/na-ctgA"},{"text":"nA123","link":"/na... [Product Listing Internal] Load More Products For Params: id: {"type":"category","value":"A.123.456"} filters: undefined @@ -588,7 +588,7 @@ describe('Shopping Store', () => { [Product Listing] Load More Products: id: {"type":"category","value":"A.123.456"} [Viewconf Internal] Set Breadcrumb Data: - breadcrumbData: [{"text":"nA","link":"/nA-catA"},{"text":"nA123","link":"/nA... + breadcrumbData: [{"text":"nA","link":"/na-ctgA"},{"text":"nA123","link":"/na... `); })); }); @@ -686,7 +686,7 @@ describe('Shopping Store', () => { [Product Listing] Load More Products: id: {"type":"category","value":"A.123.456"} [Viewconf Internal] Set Breadcrumb Data: - breadcrumbData: [{"text":"nA","link":"/nA-catA"},{"text":"nA123","link":"/nA... + breadcrumbData: [{"text":"nA","link":"/na-ctgA"},{"text":"nA123","link":"/na... [Product Listing Internal] Load More Products For Params: id: {"type":"category","value":"A.123.456"} filters: undefined @@ -713,7 +713,7 @@ describe('Shopping Store', () => { [Product Listing] Load More Products: id: {"type":"category","value":"A.123.456"} [Viewconf Internal] Set Breadcrumb Data: - breadcrumbData: [{"text":"nA","link":"/nA-catA"},{"text":"nA123","link":"/nA... + breadcrumbData: [{"text":"nA","link":"/na-ctgA"},{"text":"nA123","link":"/na... `); })); }); diff --git a/src/app/core/utils/link-parser.ts b/src/app/core/utils/link-parser.ts index 35c8bb5453..1131c66485 100644 --- a/src/app/core/utils/link-parser.ts +++ b/src/app/core/utils/link-parser.ts @@ -14,8 +14,7 @@ export class LinkParser { // TODO: use ProductRoutePipe return `${prefix}/sku${value}`; case 'category': - // TODO: the configuration parameter currently only works for first level categories - // TODO: use CategoryRoutePipe + // TODO: use CategoryRoutePipe for SEO URLs return `${prefix}/categoryref/${value}${unitName}`; case 'page': // CMS managed pages link diff --git a/src/app/core/utils/routing.ts b/src/app/core/utils/routing.ts index 77bd93007a..8ad37bd7c0 100644 --- a/src/app/core/utils/routing.ts +++ b/src/app/core/utils/routing.ts @@ -27,4 +27,22 @@ export function addGlobalGuard( /** * RegEx that finds reserved characters that should not be contained in non functional parts of routes/URLs (e.g product slugs for SEO) */ + +// not-dead-code export const reservedCharactersRegEx = /[ &\(\)=]/g; + +/** + * Sanitize slug data (remove reserved characters, clean up obsolete '-', lower case, capitalize identifiers) + */ +export function sanitizeSlugData(slugData: string) { + return ( + slugData + ?.replace(reservedCharactersRegEx, '-') + .replace(/-+/g, '-') + .replace(/-+$/, '') + .toLowerCase() + .replace('pg', 'Pg') + .replace('sku', 'Sku') + .replace('ctg', 'Ctg') || '' + ); +} diff --git a/src/app/extensions/seo/store/seo/seo.effects.ts b/src/app/extensions/seo/store/seo/seo.effects.ts index a91aa01d1f..8101edca6e 100644 --- a/src/app/extensions/seo/store/seo/seo.effects.ts +++ b/src/app/extensions/seo/store/seo/seo.effects.ts @@ -11,6 +11,7 @@ import { isEqual } from 'lodash-es'; import { Subject, combineLatest, merge, race } from 'rxjs'; import { distinctUntilChanged, filter, map, switchMap, takeWhile, tap } from 'rxjs/operators'; +import { CategoryView } from 'ish-core/models/category-view/category-view.model'; import { CategoryHelper } from 'ish-core/models/category/category.model'; import { ProductView } from 'ish-core/models/product-view/product-view.model'; import { ProductCompletenessLevel, ProductHelper } from 'ish-core/models/product/product.model'; @@ -64,7 +65,9 @@ export class SeoEffects { // PRODUCT PAGE this.productPage$.pipe(map(product => this.baseURL + generateProductUrl(product).substring(1))), // CATEGORY / FAMILY PAGE - this.categoryPage$.pipe(map(category => this.baseURL + generateCategoryUrl(category).substring(1))), + this.categoryPage$.pipe( + map((category: CategoryView) => this.baseURL + generateCategoryUrl(category).substring(1)) + ), // DEFAULT this.appRef.isStable.pipe( whenTruthy(),