diff --git a/src/app/core/facades/shopping.facade.ts b/src/app/core/facades/shopping.facade.ts index 7bb5b68c805..6b7a9e67392 100644 --- a/src/app/core/facades/shopping.facade.ts +++ b/src/app/core/facades/shopping.facade.ts @@ -6,7 +6,13 @@ import { debounce, debounceTime, filter, map, switchMap, tap } from 'rxjs/operat import { ProductListingID } from 'ish-core/models/product-listing/product-listing.model'; import { ProductCompletenessLevel, ProductHelper } from 'ish-core/models/product/product.model'; import { addProductToBasket } from 'ish-core/store/customer/basket'; -import { getCategoryLoading, getSelectedCategory, getTopLevelCategories } from 'ish-core/store/shopping/categories'; +import { + getCategory, + getCategoryLoading, + getNavigationCategories, + getSelectedCategory, + getTopLevelCategories, +} from 'ish-core/store/shopping/categories'; import { addToCompare, getCompareProducts, @@ -54,6 +60,14 @@ export class ShoppingFacade { selectedCategory$ = this.store.pipe(select(getSelectedCategory)); selectedCategoryLoading$ = this.store.pipe(select(getCategoryLoading), debounceTime(500)); + category$(uniqueId: string) { + return this.store.pipe(select(getCategory(uniqueId))); + } + + navigationCategories(uniqueId: string) { + return this.store.pipe(select(getNavigationCategories(uniqueId))); + } + // PRODUCT selectedProduct$ = this.store.pipe(select(getSelectedProduct)); diff --git a/src/app/core/models/category-view/category-view.model.spec.ts b/src/app/core/models/category-view/category-view.model.spec.ts index c5257421d25..7ac7e4402b3 100644 --- a/src/app/core/models/category-view/category-view.model.spec.ts +++ b/src/app/core/models/category-view/category-view.model.spec.ts @@ -39,8 +39,8 @@ describe('Category View Model', () => { ]); const view = createCategoryView(tree, '123'); - expect(view.hasChildren()).toBeFalse(); - expect(view.children()).toBeEmpty(); + expect(view.hasChildren).toBeFalse(); + expect(view.children).toBeEmpty(); }); const cat1 = { @@ -60,34 +60,16 @@ describe('Category View Model', () => { const tree = categoryTree([cat1, cat11]); const view = createCategoryView(tree, '123'); - expect(view.hasChildren()).toBeTrue(); - expect(view.children()).toHaveLength(1); + expect(view.hasChildren).toBeTrue(); + expect(view.children).toHaveLength(1); - expect(view.children()[0].uniqueId).toEqual('123.456'); + expect(view.children[0]).toEqual('123.456'); }); - it('should provide methods to check if a node in a deep complex tree has children', () => { - const tree = categoryTree([cat1, cat11, cat111]); - - const view = createCategoryView(tree, '123'); - expect(view.hasChildren()).toBeTrue(); - expect(view.children()).toHaveLength(1); - - const subCategory = view.children()[0]; - expect(subCategory.uniqueId).toEqual('123.456'); - expect(subCategory.hasChildren()).toBeTrue(); - expect(subCategory.children()).toHaveLength(1); - - const subSubCategory = subCategory.children()[0]; - expect(subSubCategory.uniqueId).toEqual('123.456.789'); - expect(subSubCategory.hasChildren()).toBeFalse(); - expect(subSubCategory.children()).toBeEmpty(); - }); - - it('should provide acces to the category path of a category', () => { + it('should provide access to the category path of a category', () => { const tree = categoryTree([cat1, cat11, cat111]); const view = createCategoryView(tree, '123.456.789'); - expect(view.pathCategories().map(v => v.uniqueId)).toEqual(['123', '123.456', '123.456.789']); + expect(view.categoryPath).toEqual(['123', '123.456', '123.456.789']); }); }); 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 77700d423f0..81807271fcd 100644 --- a/src/app/core/models/category-view/category-view.model.ts +++ b/src/app/core/models/category-view/category-view.model.ts @@ -5,9 +5,8 @@ import { Category } from 'ish-core/models/category/category.model'; * View on a {@link Category} with additional methods for navigating to sub categories or category path */ export interface CategoryView extends Category { - children(): CategoryView[]; - hasChildren(): boolean; - pathCategories(): CategoryView[]; + children: string[]; + hasChildren: boolean; } export function createCategoryView(tree: CategoryTree, uniqueId: string): CategoryView { @@ -20,8 +19,7 @@ export function createCategoryView(tree: CategoryTree, uniqueId: string): Catego return { ...tree.nodes[uniqueId], - hasChildren: () => !!tree.edges[uniqueId] && !!tree.edges[uniqueId].length, - children: () => (tree.edges[uniqueId] || []).map(id => createCategoryView(tree, id)), - pathCategories: () => tree.nodes[uniqueId].categoryPath.map(id => createCategoryView(tree, id)), + children: tree.edges[uniqueId] || [], + hasChildren: !!tree.edges[uniqueId] && !!tree.edges[uniqueId].length, }; } diff --git a/src/app/core/routing/category/category.route.ts b/src/app/core/routing/category/category.route.ts index 8d0ee81b8ea..45f0d78cf18 100644 --- a/src/app/core/routing/category/category.route.ts +++ b/src/app/core/routing/category/category.route.ts @@ -2,15 +2,15 @@ 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 } from 'ish-core/store/core/router'; -export function generateLocalizedCategorySlug(category: CategoryView) { +export function generateLocalizedCategorySlug(category: Category) { if (!category || !category.categoryPath.length) { return ''; } - const lastCat = category.pathCategories()[category.categoryPath.length - 1].name; + const lastCat = category.name; return lastCat ? lastCat.replace(/ /g, '-') : ''; } @@ -37,7 +37,7 @@ export function matchCategoryRoute(segments: UrlSegment[]): UrlMatchResult { return; } -export function generateCategoryUrl(category: CategoryView): string { +export function generateCategoryUrl(category: Category): string { if (!category) { return '/'; } 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 39d20f465a9..072fa4b1628 100644 --- a/src/app/core/store/shopping/categories/categories.selectors.spec.ts +++ b/src/app/core/store/shopping/categories/categories.selectors.spec.ts @@ -19,8 +19,10 @@ import { loadTopLevelCategoriesSuccess, } from './categories.actions'; import { + getCategory, getCategoryEntities, getCategoryLoading, + getNavigationCategories, getSelectedCategory, getTopLevelCategories, isTopLevelCategoriesLoaded, @@ -35,8 +37,7 @@ describe('Categories Selectors', () => { beforeEach(() => { prod = { sku: 'sku' } as Product; - cat = { uniqueId: 'Aa', categoryPath: ['Aa'] } as Category; - cat.hasOnlineProducts = true; + cat = { uniqueId: 'Aa', categoryPath: ['Aa'], hasOnlineProducts: true } as Category; @Component({ template: 'dummy' }) class DummyComponent {} @@ -63,6 +64,7 @@ describe('Categories Selectors', () => { it('should not select any selected category when used', () => { expect(getSelectedCategory(store$.state)).toBeUndefined(); + expect(getCategory(cat.uniqueId)(store$.state)).toBeUndefined(); }); it('should not select any top level categories when used', () => { @@ -117,6 +119,7 @@ describe('Categories Selectors', () => { it('should not select the irrelevant category when used', () => { expect(getSelectedCategory(store$.state)).toBeUndefined(); + expect(getCategory(cat.uniqueId)(store$.state).uniqueId).toEqual(cat.uniqueId); }); }); @@ -133,18 +136,22 @@ describe('Categories Selectors', () => { it('should select the selected category when used', () => { expect(getSelectedCategory(store$.state).uniqueId).toEqual(cat.uniqueId); + expect(getCategory(cat.uniqueId)(store$.state).uniqueId).toEqual(cat.uniqueId); }); }); }); describe('loading top level categories', () => { - let catA: Category; - let catB: Category; - beforeEach(() => { - catA = { uniqueId: 'A', categoryPath: ['A'] } as Category; - catB = { uniqueId: 'B', categoryPath: ['B'] } as Category; - store$.dispatch(loadTopLevelCategoriesSuccess({ categories: categoryTree([catA, catB]) })); + const catA = { name: 'name_A', uniqueId: 'A', categoryPath: ['A'] } as Category; + const catA1 = { name: 'name_A.1', uniqueId: 'A.1', categoryPath: ['A', 'A.1'] } as Category; + const catA1a = { name: 'name_A.1.a', uniqueId: 'A.1.a', categoryPath: ['A', 'A.1', 'A.1.a'] } as Category; + const catA1b = { name: 'name_A.1.b', uniqueId: 'A.1.b', categoryPath: ['A', 'A.1', 'A.1.b'] } as Category; + const catA2 = { name: 'name_A.2', uniqueId: 'A.2', categoryPath: ['A', 'A.2'] } as Category; + const catB = { name: 'name_B', uniqueId: 'B', categoryPath: ['B'] } as Category; + store$.dispatch( + loadTopLevelCategoriesSuccess({ categories: categoryTree([catA, catA1, catA1a, catA1b, catA2, catB]) }) + ); }); it('should select root categories when used', () => { @@ -154,5 +161,62 @@ describe('Categories Selectors', () => { it('should remember if top level categories are loaded', () => { expect(isTopLevelCategoriesLoaded(store$.state)).toBeTrue(); }); + + describe('selecting navigation categories', () => { + it('should select top level categories when no argument was supplied', () => { + expect(getNavigationCategories(undefined)(store$.state)).toMatchInlineSnapshot(` + Array [ + Object { + "name": "name_A", + "uniqueId": "A", + "url": "/name_A-catA", + }, + Object { + "name": "name_B", + "uniqueId": "B", + "url": "/name_B-catB", + }, + ] + `); + }); + + it('should select sub categories when sub category is selected', () => { + expect(getNavigationCategories('A')(store$.state)).toMatchInlineSnapshot(` + Array [ + Object { + "name": "name_A.1", + "uniqueId": "A.1", + "url": "/name_A.1-catA.1", + }, + Object { + "name": "name_A.2", + "uniqueId": "A.2", + "url": "/name_A.2-catA.2", + }, + ] + `); + }); + + it('should select deeper sub categories when deeper sub category is selected', () => { + expect(getNavigationCategories('A.1')(store$.state)).toMatchInlineSnapshot(` + Array [ + Object { + "name": "name_A.1.a", + "uniqueId": "A.1.a", + "url": "/name_A.1.a-catA.1.a", + }, + Object { + "name": "name_A.1.b", + "uniqueId": "A.1.b", + "url": "/name_A.1.b-catA.1.b", + }, + ] + `); + }); + + it('should be empty when selecting leaves', () => { + expect(getNavigationCategories('A.1.a')(store$.state)).toBeEmpty(); + }); + }); }); }); diff --git a/src/app/core/store/shopping/categories/categories.selectors.ts b/src/app/core/store/shopping/categories/categories.selectors.ts index 001e5d6944f..262d5c8fddb 100644 --- a/src/app/core/store/shopping/categories/categories.selectors.ts +++ b/src/app/core/store/shopping/categories/categories.selectors.ts @@ -1,6 +1,9 @@ -import { createSelector } from '@ngrx/store'; +import { createSelector, createSelectorFactory, defaultMemoize } from '@ngrx/store'; +import { isEqual } from 'lodash-es'; +import { CategoryTree, CategoryTreeHelper } from 'ish-core/models/category-tree/category-tree.model'; import { createCategoryView } from 'ish-core/models/category-view/category-view.model'; +import { generateCategoryUrl } from 'ish-core/routing/category/category.route'; import { selectRouteParam } from 'ish-core/store/core/router'; import { ShoppingState, getShoppingState } from 'ish-core/store/shopping/shopping-store'; @@ -13,10 +16,21 @@ export const getCategoryTree = createSelector(getCategoriesState, state => state */ export const getCategoryEntities = createSelector(getCategoryTree, tree => tree.nodes); +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) + ); + /** * Retrieves the currently resolved selected category. */ -export const getSelectedCategory = createSelector( +export const getSelectedCategory = createSelectorFactory(projector => defaultMemoize(projector, undefined, isEqual))( getCategoryTree, selectRouteParam('categoryUniqueId'), createCategoryView @@ -29,3 +43,29 @@ export const getTopLevelCategories = createSelector(getCategoryTree, tree => ); export const isTopLevelCategoriesLoaded = createSelector(getCategoriesState, state => state.topLevelLoaded); + +export interface NavigationCategory { + uniqueId: string; + name: string; + url: string; +} + +function mapNavigationCategoryFromId(uniqueId: string): NavigationCategory { + return { + uniqueId, + name: this.nodes[uniqueId].name, + url: generateCategoryUrl(this.nodes[uniqueId]), + }; +} + +export const getNavigationCategories = (uniqueId: string) => + createSelectorFactory(projector => defaultMemoize(projector, CategoryTreeHelper.equals, isEqual))( + getCategoryTree, + (tree: CategoryTree): NavigationCategory[] => { + if (!uniqueId) { + return tree.rootIds.map(mapNavigationCategoryFromId.bind(tree)); + } + const subTree = CategoryTreeHelper.subTree(tree, uniqueId); + return subTree.edges[uniqueId] ? subTree.edges[uniqueId].map(mapNavigationCategoryFromId.bind(subTree)) : []; + } + ); diff --git a/src/app/core/store/shopping/shopping-store.spec.ts b/src/app/core/store/shopping/shopping-store.spec.ts index 7838f92d855..08047ba99ae 100644 --- a/src/app/core/store/shopping/shopping-store.spec.ts +++ b/src/app/core/store/shopping/shopping-store.spec.ts @@ -480,10 +480,6 @@ describe('Shopping Store', () => { categories: tree(A,A.123) [Shopping] Load Category Success: categories: tree(A.123,A.123.456) - [Shopping] Load Category: - categoryId: "A.123" - [Shopping] Load Category Success: - categories: tree(A.123,A.123.456) @ngrx/router-store/navigated: routerState: {"url":"/category/A.123.456","params":{"categoryUniqueId":"A... event: {"id":1,"url":"/category/A.123.456"} @@ -720,10 +716,6 @@ describe('Shopping Store', () => { categories: tree(A,A.123) [Shopping] Load Category Success: categories: tree(A.123,A.123.456) - [Shopping] Load Category: - categoryId: "A.123" - [Shopping] Load Category Success: - categories: tree(A.123,A.123.456) @ngrx/router-store/navigated: routerState: {"url":"/category/A.123.456/product/P1","params":{"categoryU... event: {"id":1,"url":"/category/A.123.456/product/P1"} diff --git a/src/app/pages/category/category-categories/category-categories.component.html b/src/app/pages/category/category-categories/category-categories.component.html index 8a1a20c4f52..37d0fe60fc6 100644 --- a/src/app/pages/category/category-categories/category-categories.component.html +++ b/src/app/pages/category/category-categories/category-categories.component.html @@ -2,7 +2,7 @@
{{ 'search.mobile.filter.trigger' | translate }} @@ -13,7 +13,7 @@

{{ category.name }}

- +
diff --git a/src/app/pages/category/category-list/category-list.component.html b/src/app/pages/category/category-list/category-list.component.html index e3dd78bda4a..4f76e8d382e 100644 --- a/src/app/pages/category/category-list/category-list.component.html +++ b/src/app/pages/category/category-list/category-list.component.html @@ -1,5 +1,5 @@ diff --git a/src/app/pages/category/category-list/category-list.component.ts b/src/app/pages/category/category-list/category-list.component.ts index 7139f2bb0ad..647e2e3a947 100644 --- a/src/app/pages/category/category-list/category-list.component.ts +++ b/src/app/pages/category/category-list/category-list.component.ts @@ -1,17 +1,10 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; -import { CategoryView } from 'ish-core/models/category-view/category-view.model'; -import { Category } from 'ish-core/models/category/category.model'; - @Component({ selector: 'ish-category-list', templateUrl: './category-list.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class CategoryListComponent { - @Input() categories: Category[]; - - trackByFn(_, item: CategoryView) { - return item.uniqueId; - } + @Input() categories: string[]; } diff --git a/src/app/pages/category/category-navigation/category-navigation.component.html b/src/app/pages/category/category-navigation/category-navigation.component.html index 83673a98bf2..cecc229bea4 100644 --- a/src/app/pages/category/category-navigation/category-navigation.component.html +++ b/src/app/pages/category/category-navigation/category-navigation.component.html @@ -1,19 +1,15 @@ -
+
diff --git a/src/app/pages/category/category-navigation/category-navigation.component.ts b/src/app/pages/category/category-navigation/category-navigation.component.ts index 2f57f9de151..3a0443b701a 100644 --- a/src/app/pages/category/category-navigation/category-navigation.component.ts +++ b/src/app/pages/category/category-navigation/category-navigation.component.ts @@ -1,16 +1,28 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; -import { CategoryView } from 'ish-core/models/category-view/category-view.model'; +import { ShoppingFacade } from 'ish-core/facades/shopping.facade'; import { CategoryHelper } from 'ish-core/models/category/category.model'; +import { NavigationCategory } from 'ish-core/store/shopping/categories'; @Component({ selector: 'ish-category-navigation', templateUrl: './category-navigation.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CategoryNavigationComponent { - @Input() category: CategoryView; - @Input() categoryNavigationLevel: number; +export class CategoryNavigationComponent implements OnInit { + @Input() uniqueId: string; + + navigationCategories$: Observable; + currentCategoryId$: Observable; categoryEquals = CategoryHelper.equals; + + constructor(private shoppingFacade: ShoppingFacade) {} + + ngOnInit() { + this.navigationCategories$ = this.shoppingFacade.navigationCategories(this.uniqueId); + this.currentCategoryId$ = this.shoppingFacade.selectedCategory$.pipe(map(c => c?.uniqueId)); + } } diff --git a/src/app/pages/category/category-page.component.html b/src/app/pages/category/category-page.component.html index b6061da61e7..1d94df947e3 100644 --- a/src/app/pages/category/category-page.component.html +++ b/src/app/pages/category/category-page.component.html @@ -1,6 +1,6 @@ - + @@ -12,7 +12,7 @@ - +

{{ category.name }}

diff --git a/src/app/pages/category/category-tile/category-tile.component.html b/src/app/pages/category/category-tile/category-tile.component.html index d5caa19b7cc..657ef39b064 100644 --- a/src/app/pages/category/category-tile/category-tile.component.html +++ b/src/app/pages/category/category-tile/category-tile.component.html @@ -1,4 +1,8 @@ -
+

{{ category.name }}

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 170e778149f..9166b6a7faa 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 @@ -1,7 +1,10 @@ import { ComponentFixture, TestBed, async } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { MockComponent, MockPipe } from 'ng-mocks'; +import { of } from 'rxjs'; +import { anything, 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/routing/category/category-route.pipe'; @@ -16,10 +19,33 @@ describe('Category Tile Component', () => { let fixture: ComponentFixture; let element: HTMLElement; + const category = { + uniqueId: 'A', + categoryPath: ['A'], + images: [ + { + name: 'front S', + type: 'Image', + imageActualHeight: 110, + imageActualWidth: 110, + viewID: 'front', + effectiveUrl: '/assets/product_img/a.jpg', + typeID: 'S', + primaryImage: false, + }, + ], + } as Category; + beforeEach(async(() => { + const shoppingFacade = mock(ShoppingFacade); + when(shoppingFacade.category$(anything())).thenReturn( + of(createCategoryView(categoryTree([category]), category.uniqueId)) + ); + TestBed.configureTestingModule({ imports: [RouterTestingModule], declarations: [CategoryTileComponent, MockComponent(CategoryImageComponent), MockPipe(CategoryRoutePipe)], + providers: [{ provide: ShoppingFacade, useFactory: () => instance(shoppingFacade) }], }).compileComponents(); })); @@ -27,24 +53,6 @@ describe('Category Tile Component', () => { fixture = TestBed.createComponent(CategoryTileComponent); component = fixture.componentInstance; element = fixture.nativeElement; - const category = { - uniqueId: 'A', - categoryPath: ['A'], - images: [ - { - name: 'front S', - type: 'Image', - imageActualHeight: 110, - imageActualWidth: 110, - viewID: 'front', - effectiveUrl: '/assets/product_img/a.jpg', - typeID: 'S', - primaryImage: false, - }, - ], - } as Category; - component.category = createCategoryView(categoryTree([category]), category.uniqueId); - fixture.detectChanges(); }); it('should be created', () => { 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 e686f5d793f..36e292b7c7b 100644 --- a/src/app/pages/category/category-tile/category-tile.component.ts +++ b/src/app/pages/category/category-tile/category-tile.component.ts @@ -1,5 +1,7 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ShoppingFacade } from 'ish-core/facades/shopping.facade'; import { CategoryView } from 'ish-core/models/category-view/category-view.model'; /** @@ -7,16 +9,24 @@ import { CategoryView } from 'ish-core/models/category-view/category-view.model' * category using {@link CategoryImageComponent}. * * @example - * + * */ @Component({ selector: 'ish-category-tile', templateUrl: './category-tile.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CategoryTileComponent { +export class CategoryTileComponent implements OnInit { /** * The Category to render a tile for */ - @Input() category: CategoryView; + @Input() categoryUniqueId: string; + + category$: Observable; + + constructor(private shoppingFacade: ShoppingFacade) {} + + ngOnInit() { + this.category$ = this.shoppingFacade.category$(this.categoryUniqueId); + } }