From b08443c33a033622c658a6c924bb12ca2cdad31c Mon Sep 17 00:00:00 2001 From: Stefan Hauke Date: Fri, 23 Sep 2022 15:48:58 +0200 Subject: [PATCH] feat: CMS navigation category component * category tree data handling for CMS navigation category component --- src/app/core/facades/shopping.facade.ts | 10 +- .../navigation-category.model.ts | 1 + .../categories/categories.service.spec.ts | 18 +- .../services/categories/categories.service.ts | 40 ++- .../shopping/categories/categories.actions.ts | 10 + .../categories/categories.effects.spec.ts | 38 ++- .../shopping/categories/categories.effects.ts | 15 ++ .../shopping/categories/categories.reducer.ts | 11 +- .../categories/categories.selectors.spec.ts | 151 +++++++++++ .../categories/categories.selectors.ts | 42 ++- src/app/shared/cms/cms.module.ts | 9 + .../cms-navigation-category.component.html | 62 +++++ .../cms-navigation-category.component.spec.ts | 246 ++++++++++++++++++ .../cms-navigation-category.component.ts | 63 +++++ src/app/shared/shared.module.ts | 2 + 15 files changed, 693 insertions(+), 25 deletions(-) create mode 100644 src/app/shared/cms/components/cms-navigation-category/cms-navigation-category.component.html create mode 100644 src/app/shared/cms/components/cms-navigation-category/cms-navigation-category.component.spec.ts create mode 100644 src/app/shared/cms/components/cms-navigation-category/cms-navigation-category.component.ts diff --git a/src/app/core/facades/shopping.facade.ts b/src/app/core/facades/shopping.facade.ts index c01da7fc9b..4e50250947 100644 --- a/src/app/core/facades/shopping.facade.ts +++ b/src/app/core/facades/shopping.facade.ts @@ -14,8 +14,10 @@ import { getCategory, getCategoryIdByRefId, getNavigationCategories, + getNavigationCategoryTree, getSelectedCategory, loadCategoryByRef, + loadCategoryTree, loadTopLevelCategories, } from 'ish-core/store/shopping/categories'; import { getAvailableFilter } from 'ish-core/store/shopping/filter'; @@ -69,8 +71,14 @@ export class ShoppingFacade { } return this.store.pipe( select(getNavigationCategories(uniqueId)), + // prevent to display an empty navigation bar after login/logout); filter(categories => !!categories?.length) - ); // prevent to display an empty navigation bar after login/logout); + ); + } + + navigationCategoryTree$(categoryRef: string, depth: number) { + this.store.dispatch(loadCategoryTree({ categoryRef, depth })); + return this.store.pipe(select(getNavigationCategoryTree(categoryRef, depth))); } // PRODUCT diff --git a/src/app/core/models/navigation-category/navigation-category.model.ts b/src/app/core/models/navigation-category/navigation-category.model.ts index acfa218dbf..d439461d20 100644 --- a/src/app/core/models/navigation-category/navigation-category.model.ts +++ b/src/app/core/models/navigation-category/navigation-category.model.ts @@ -3,4 +3,5 @@ export interface NavigationCategory { name: string; url: string; hasChildren: boolean; + children?: NavigationCategory[]; } diff --git a/src/app/core/services/categories/categories.service.spec.ts b/src/app/core/services/categories/categories.service.spec.ts index ccf37c3f73..aa7b7af287 100644 --- a/src/app/core/services/categories/categories.service.spec.ts +++ b/src/app/core/services/categories/categories.service.spec.ts @@ -24,6 +24,9 @@ describe('Categories Service', () => { when(apiServiceMock.get('categories/dummyid/dummysubid', anything())).thenReturn( of({ categoryPath: [{ id: 'blubb' }] } as CategoryData) ); + when(apiServiceMock.get('categories/dummyid@cat', anything())).thenReturn( + of({ categoryPath: [{ id: 'blubb' }] } as CategoryData) + ); TestBed.configureTestingModule({ providers: [{ provide: ApiService, useFactory: () => instance(apiServiceMock) }, provideMockStore()], }); @@ -43,7 +46,7 @@ describe('Categories Service', () => { expect(capture(apiServiceMock.get).last()[0].toString()).toMatchInlineSnapshot(`"categories"`); const options: AvailableOptions = capture(apiServiceMock.get).last()[1]; expect(options.params.toString()).toMatchInlineSnapshot( - `"imageView=NO-IMAGE&view=tree&limit=1&omitHasOnlineProducts=true"` + `"view=tree&limit=1&omitHasOnlineProducts=true&imageView=NO-IMAGE"` ); }); @@ -96,4 +99,17 @@ describe('Categories Service', () => { verify(apiServiceMock.get('categories/dummyid/dummysubid', anything())).once(); }); }); + + describe('getCategoryTree()', () => { + it('should call ApiService "categories" when called', () => { + categoriesService.getCategoryTree('dummyid@cat', 1); + verify(apiServiceMock.get('categories/dummyid@cat', anything())).once(); + + expect(capture(apiServiceMock.get).last()[0].toString()).toMatchInlineSnapshot(`"categories/dummyid@cat"`); + const options: AvailableOptions = capture(apiServiceMock.get).last()[1]; + expect(options.params.toString()).toMatchInlineSnapshot( + `"view=tree&limit=1&omitHasOnlineProducts=true&imageView=NO-IMAGE"` + ); + }); + }); }); diff --git a/src/app/core/services/categories/categories.service.ts b/src/app/core/services/categories/categories.service.ts index 5ce46f4fc9..6ef145257d 100644 --- a/src/app/core/services/categories/categories.service.ts +++ b/src/app/core/services/categories/categories.service.ts @@ -47,12 +47,7 @@ export class CategoriesService { * @returns A Sorted list of top level categories with sub categories. */ getTopLevelCategories(limit: number): Observable { - let params = new HttpParams().set('imageView', 'NO-IMAGE'); - if (limit > 0) { - params = params.set('view', 'tree').set('limit', limit.toString()).set('omitHasOnlineProducts', 'true'); - } - - return this.apiService.get('categories', { sendSPGID: true, params }).pipe( + return this.apiService.get('categories', { sendSPGID: true, params: this.setTreeParams(limit) }).pipe( unpackEnvelope(), map(categoriesData => categoriesData @@ -61,4 +56,37 @@ export class CategoriesService { ) ); } + + /** + * Get the category tree data for a given categoryRef up to the given depth limit. + * If no limit is given or the limit is 0 the simple category detail REST call is made. + * + * @param categoryRef The categoryRef for the category of interest + * @param depth The number of depth levels to be returned in a hierarchical structure. + * @returns A category tree for the given category ref. + */ + getCategoryTree(categoryRef: string, depth?: number): Observable { + if (!categoryRef) { + return throwError(() => new Error('getCategoryTree() called without categoryRef')); + } + return this.apiService + .get(`categories/${categoryRef}`, { sendSPGID: true, params: this.setTreeParams(depth) }) + .pipe(map(categoryData => this.categoryMapper.fromData(categoryData))); + } + + /** + * Set the necessary parameters for a REST call that should return a categories tree for the given depth. + * Http params for a tree REST call are only needed if a depth > 0 is set. + */ + private setTreeParams(depth?: number): HttpParams { + let params = new HttpParams(); + if (depth > 0) { + params = params + .set('view', 'tree') + .set('limit', depth.toString()) + .set('omitHasOnlineProducts', 'true') + .set('imageView', 'NO-IMAGE'); + } + return params; + } } diff --git a/src/app/core/store/shopping/categories/categories.actions.ts b/src/app/core/store/shopping/categories/categories.actions.ts index 7a0b26dd6f..90f6d67082 100644 --- a/src/app/core/store/shopping/categories/categories.actions.ts +++ b/src/app/core/store/shopping/categories/categories.actions.ts @@ -12,6 +12,16 @@ export const loadTopLevelCategoriesSuccess = createAction( payload<{ categories: CategoryTree }>() ); +export const loadCategoryTree = createAction( + '[Categories Internal] Load a specific category tree', + payload<{ categoryRef: string; depth: number }>() +); + +export const loadCategoryTreeSuccess = createAction( + '[Categories API] Load a specific category tree success', + payload<{ categories: CategoryTree }>() +); + export const loadCategory = createAction('[Categories Internal] Load Category', payload<{ categoryId: string }>()); export const loadCategoryFail = createAction('[Categories API] Load Category Fail', httpError()); 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 b1a52db6ad..776be50b61 100644 --- a/src/app/core/store/shopping/categories/categories.effects.spec.ts +++ b/src/app/core/store/shopping/categories/categories.effects.spec.ts @@ -5,7 +5,7 @@ import { provideMockActions } from '@ngrx/effects/testing'; import { Action, Store } from '@ngrx/store'; import { cold, hot } from 'jasmine-marbles'; import { Observable, noop, of, throwError } from 'rxjs'; -import { anyNumber, anything, capture, instance, mock, spy, verify, when } from 'ts-mockito'; +import { anyNumber, anyString, anything, capture, instance, mock, spy, verify, when } from 'ts-mockito'; import { MAIN_NAVIGATION_MAX_SUB_CATEGORIES_DEPTH } from 'ish-core/configurations/injection-keys'; import { CategoryView } from 'ish-core/models/category-view/category-view.model'; @@ -23,6 +23,8 @@ import { loadCategoryByRef, loadCategoryFail, loadCategorySuccess, + loadCategoryTree, + loadCategoryTreeSuccess, loadTopLevelCategories, loadTopLevelCategoriesFail, loadTopLevelCategoriesSuccess, @@ -43,6 +45,12 @@ describe('Categories Effects', () => { { uniqueId: '456', categoryPath: ['456'] }, ] as Category[]); + const CATEGORIES_TREE = categoryTree([ + { uniqueId: '123', categoryRef: '123@cat', categoryPath: ['123'] }, + { uniqueId: '123.456', categoryRef: '456@cat', categoryPath: ['123', '456'] }, + { uniqueId: '123.456.789', categoryRef: '789@cat', categoryPath: ['123', '456', '789'] }, + ] as Category[]); + beforeEach(() => { categoriesServiceMock = mock(CategoriesService); when(categoriesServiceMock.getCategory('123')).thenReturn( @@ -55,6 +63,7 @@ describe('Categories Effects', () => { throwError(() => makeHttpError({ message: 'invalid category' })) ); when(categoriesServiceMock.getTopLevelCategories(anyNumber())).thenReturn(of(TOP_LEVEL_CATEGORIES)); + when(categoriesServiceMock.getCategoryTree(anything(), anyNumber())).thenReturn(of(CATEGORIES_TREE)); TestBed.configureTestingModule({ imports: [ @@ -314,6 +323,33 @@ describe('Categories Effects', () => { }); }); + describe('loadCategoryTree$', () => { + it('should call the categoriesService for loadCategoryTree action', done => { + const action = loadCategoryTree({ categoryRef: '123@cat', depth: 1 }); + actions$ = of(personalizationStatusDetermined(), action); + + effects.loadCategoryTree$.subscribe(() => { + verify(categoriesServiceMock.getCategoryTree(anyString(), anyNumber())).once(); + expect(capture(categoriesServiceMock.getCategoryTree).last()).toMatchInlineSnapshot(` + [ + "123@cat", + 1, + ] + `); + done(); + }); + }); + + it('should map to action of type loadCategoryTreeSuccess', () => { + const action = loadCategoryTree({ categoryRef: '123@cat', depth: 2 }); + const completion = loadCategoryTreeSuccess({ categories: CATEGORIES_TREE }); + actions$ = hot('b-a-a-a', { a: action, b: personalizationStatusDetermined() }); + const expected$ = cold('--c-c-c', { c: completion }); + + expect(effects.loadCategoryTree$).toBeObservable(expected$); + }); + }); + describe('redirectIfErrorInCategories$', () => { it('should call error service if triggered', done => { actions$ = of(loadCategoryFail({ error: makeHttpError({ status: 404 }) })); diff --git a/src/app/core/store/shopping/categories/categories.effects.ts b/src/app/core/store/shopping/categories/categories.effects.ts index 3dd5f95dbb..8f937aa17a 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 { HttpStatusCodeService } from 'ish-core/utils/http-status-code/http-stat import { InjectSingle } from 'ish-core/utils/injection'; import { mapErrorToAction, + mapToPayload, mapToPayloadProperty, useCombinedObservableOnAction, whenTruthy, @@ -27,6 +28,8 @@ import { loadCategoryByRef, loadCategoryFail, loadCategorySuccess, + loadCategoryTree, + loadCategoryTreeSuccess, loadTopLevelCategories, loadTopLevelCategoriesFail, loadTopLevelCategoriesSuccess, @@ -117,6 +120,18 @@ export class CategoriesEffects { ) ); + loadCategoryTree$ = createEffect(() => + this.actions$.pipe( + useCombinedObservableOnAction(this.actions$.pipe(ofType(loadCategoryTree)), personalizationStatusDetermined), + mapToPayload(), + switchMap(({ categoryRef, depth }) => + this.categoryService + .getCategoryTree(categoryRef, depth) + .pipe(map(categories => loadCategoryTreeSuccess({ categories }))) + ) + ) + ); + productOrCategoryChanged$ = createEffect(() => this.actions$.pipe( ofType(routerNavigatedAction), diff --git a/src/app/core/store/shopping/categories/categories.reducer.ts b/src/app/core/store/shopping/categories/categories.reducer.ts index d81d25357b..5c17302208 100644 --- a/src/app/core/store/shopping/categories/categories.reducer.ts +++ b/src/app/core/store/shopping/categories/categories.reducer.ts @@ -2,7 +2,12 @@ import { createReducer, on } from '@ngrx/store'; import { CategoryTree, CategoryTreeHelper } from 'ish-core/models/category-tree/category-tree.model'; -import { loadCategoryFail, loadCategorySuccess, loadTopLevelCategoriesSuccess } from './categories.actions'; +import { + loadCategoryFail, + loadCategorySuccess, + loadCategoryTreeSuccess, + loadTopLevelCategoriesSuccess, +} from './categories.actions'; export interface CategoriesState { categories: CategoryTree; @@ -14,7 +19,7 @@ const initialState: CategoriesState = { function mergeCategories( state: CategoriesState, - action: ReturnType + action: ReturnType ) { const loadedTree = action.payload.categories; const categories = CategoryTreeHelper.merge(state.categories, loadedTree); @@ -32,5 +37,5 @@ export const categoriesReducer = createReducer( ...state, }) ), - on(loadCategorySuccess, loadTopLevelCategoriesSuccess, mergeCategories) + on(loadCategorySuccess, loadCategoryTreeSuccess, loadTopLevelCategoriesSuccess, mergeCategories) ); 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 59c35f7d00..fd382209b1 100644 --- a/src/app/core/store/shopping/categories/categories.selectors.spec.ts +++ b/src/app/core/store/shopping/categories/categories.selectors.spec.ts @@ -15,6 +15,7 @@ import { loadCategory, loadCategoryFail, loadCategorySuccess, + loadCategoryTreeSuccess, loadTopLevelCategoriesSuccess, } from './categories.actions'; import { @@ -23,6 +24,7 @@ import { getCategoryEntities, getCategoryIdByRefId, getNavigationCategories, + getNavigationCategoryTree, getSelectedCategory, } from './categories.selectors'; @@ -190,12 +192,14 @@ describe('Categories Selectors', () => { expect(getNavigationCategories(undefined)(store$.state)).toMatchInlineSnapshot(` [ { + "children": undefined, "hasChildren": true, "name": "name_A", "uniqueId": "A", "url": "/name_a-ctgA", }, { + "children": undefined, "hasChildren": false, "name": "name_B", "uniqueId": "B", @@ -209,12 +213,14 @@ describe('Categories Selectors', () => { expect(getNavigationCategories('A')(store$.state)).toMatchInlineSnapshot(` [ { + "children": undefined, "hasChildren": true, "name": "name_A.1", "uniqueId": "A.1", "url": "/name_a/name_a.1-ctgA.1", }, { + "children": undefined, "hasChildren": false, "name": "name_A.2", "uniqueId": "A.2", @@ -228,12 +234,14 @@ describe('Categories Selectors', () => { expect(getNavigationCategories('A.1')(store$.state)).toMatchInlineSnapshot(` [ { + "children": undefined, "hasChildren": false, "name": "name_A.1.a", "uniqueId": "A.1.a", "url": "/name_a/name_a.1/name_a.1.a-ctgA.1.a", }, { + "children": undefined, "hasChildren": false, "name": "name_A.1.b", "uniqueId": "A.1.b", @@ -249,6 +257,149 @@ describe('Categories Selectors', () => { }); }); + describe('loading category tree', () => { + beforeEach(() => { + const cA = { name: 'name_A', uniqueId: 'A', categoryRef: 'A@cat', categoryPath: ['A'] } as Category; + const cA1 = { name: 'name_A.1', uniqueId: 'A.1', categoryRef: 'A.1@cat', categoryPath: ['A', 'A.1'] } as Category; + const cA1a = { + name: 'name_A.1.a', + uniqueId: 'A.1.a', + categoryRef: 'A.1.a@cat', + categoryPath: ['A', 'A.1', 'A.1.a'], + } as Category; + const cA1b = { + name: 'name_A.1.b', + uniqueId: 'A.1.b', + categoryRef: 'A.1.b@cat', + categoryPath: ['A', 'A.1', 'A.1.b'], + } as Category; + const cA2 = { name: 'name_A.2', uniqueId: 'A.2', categoryRef: 'A.2@cat', categoryPath: ['A', 'A.2'] } as Category; + store$.dispatch(loadCategoryTreeSuccess({ categories: categoryTree([cA, cA1, cA1a, cA1b, cA2]) })); + }); + + describe('selecting navigation category tree', () => { + it('should select only the category tree root', () => { + expect(getNavigationCategoryTree('A@cat', 0)(store$.state)).toMatchInlineSnapshot(` + { + "children": undefined, + "hasChildren": true, + "name": "name_A", + "uniqueId": "A", + "url": "/name_a-ctgA", + } + `); + }); + + it('should select the category tree with only one level', () => { + expect(getNavigationCategoryTree('A@cat', 1)(store$.state)).toMatchInlineSnapshot(` + { + "children": [ + { + "children": undefined, + "hasChildren": true, + "name": "name_A.1", + "uniqueId": "A.1", + "url": "/name_a/name_a.1-ctgA.1", + }, + { + "children": undefined, + "hasChildren": false, + "name": "name_A.2", + "uniqueId": "A.2", + "url": "/name_a/name_a.2-ctgA.2", + }, + ], + "hasChildren": true, + "name": "name_A", + "uniqueId": "A", + "url": "/name_a-ctgA", + } + `); + }); + + it('should select the whole category tree', () => { + expect(getNavigationCategoryTree('A@cat', 2)(store$.state)).toMatchInlineSnapshot(` + { + "children": [ + { + "children": [ + { + "children": undefined, + "hasChildren": false, + "name": "name_A.1.a", + "uniqueId": "A.1.a", + "url": "/name_a/name_a.1/name_a.1.a-ctgA.1.a", + }, + { + "children": undefined, + "hasChildren": false, + "name": "name_A.1.b", + "uniqueId": "A.1.b", + "url": "/name_a/name_a.1/name_a.1.b-ctgA.1.b", + }, + ], + "hasChildren": true, + "name": "name_A.1", + "uniqueId": "A.1", + "url": "/name_a/name_a.1-ctgA.1", + }, + { + "children": undefined, + "hasChildren": false, + "name": "name_A.2", + "uniqueId": "A.2", + "url": "/name_a/name_a.2-ctgA.2", + }, + ], + "hasChildren": true, + "name": "name_A", + "uniqueId": "A", + "url": "/name_a-ctgA", + } + `); + }); + + it('should select sub category tree when deeper sub category is selected', () => { + expect(getNavigationCategoryTree('A.1@cat', 2)(store$.state)).toMatchInlineSnapshot(` + { + "children": [ + { + "children": undefined, + "hasChildren": false, + "name": "name_A.1.a", + "uniqueId": "A.1.a", + "url": "/name_a/name_a.1/name_a.1.a-ctgA.1.a", + }, + { + "children": undefined, + "hasChildren": false, + "name": "name_A.1.b", + "uniqueId": "A.1.b", + "url": "/name_a/name_a.1/name_a.1.b-ctgA.1.b", + }, + ], + "hasChildren": true, + "name": "name_A.1", + "uniqueId": "A.1", + "url": "/name_a/name_a.1-ctgA.1", + } + `); + }); + + it('should select only the root category if it has no subcategories', () => { + expect(getNavigationCategoryTree('A.2@cat', 2)(store$.state)).toMatchInlineSnapshot(` + { + "children": undefined, + "hasChildren": false, + "name": "name_A.2", + "uniqueId": "A.2", + "url": "/name_a/name_a.2-ctgA.2", + } + `); + }); + }); + }); + describe('load category by refId', () => { beforeEach(() => { store$.dispatch( diff --git a/src/app/core/store/shopping/categories/categories.selectors.ts b/src/app/core/store/shopping/categories/categories.selectors.ts index 5b47eb2537..20637132c1 100644 --- a/src/app/core/store/shopping/categories/categories.selectors.ts +++ b/src/app/core/store/shopping/categories/categories.selectors.ts @@ -14,9 +14,6 @@ const getCategoriesState = createSelector(getShoppingState, (state: ShoppingStat export const getCategoryTree = createSelector(getCategoriesState, state => state.categories); -/** - * Retrieve the {@link Dictionary} of {@link Category} entities. - */ export const getCategoryEntities = createSelector(getCategoryTree, tree => tree.nodes); export const getCategoryRefs = createSelector(getCategoryTree, tree => tree.categoryRefs); @@ -53,16 +50,6 @@ export const getBreadcrumbForCategoryPage = createSelectorFactory createSelectorFactory(projector => defaultMemoize(projector, CategoryTreeHelper.equals, isEqual) @@ -75,3 +62,32 @@ export const getNavigationCategories = (uniqueId: string) => ? subTree.edges[uniqueId].map(id => mapNavigationCategoryFromId(id, tree, subTree)) : []; }); + +export const getNavigationCategoryTree = (categoryRef: string, depth: number) => + createSelectorFactory(projector => resultMemoize(projector, isEqual))( + getCategoryIdByRefId(categoryRef), + getCategoryTree, + (uniqueId: string, tree: CategoryTree): NavigationCategory => + uniqueId + ? mapNavigationCategoryFromId(uniqueId, tree, CategoryTreeHelper.subTree(tree, uniqueId), depth) + : undefined + ); + +function mapNavigationCategoryFromId( + uniqueId: string, + tree: CategoryTree, + subTree?: CategoryTree, + depth?: number +): NavigationCategory { + const selected = subTree || tree; + return { + uniqueId, + name: selected.nodes[uniqueId].name, + url: generateCategoryUrl(createCategoryView(tree, uniqueId)), + hasChildren: !!selected.edges[uniqueId]?.length, + children: + depth > 0 + ? selected.edges[uniqueId]?.map(childId => mapNavigationCategoryFromId(childId, tree, subTree, depth - 1)) + : undefined, + }; +} diff --git a/src/app/shared/cms/cms.module.ts b/src/app/shared/cms/cms.module.ts index 2dad449d35..39a7f53633 100644 --- a/src/app/shared/cms/cms.module.ts +++ b/src/app/shared/cms/cms.module.ts @@ -6,6 +6,7 @@ import { CMSDialogComponent } from './components/cms-dialog/cms-dialog.component import { CMSFreestyleComponent } from './components/cms-freestyle/cms-freestyle.component'; import { CMSImageEnhancedComponent } from './components/cms-image-enhanced/cms-image-enhanced.component'; import { CMSImageComponent } from './components/cms-image/cms-image.component'; +import { CMSNavigationCategoryComponent } from './components/cms-navigation-category/cms-navigation-category.component'; import { CMSNavigationLinkComponent } from './components/cms-navigation-link/cms-navigation-link.component'; import { CMSNavigationPageComponent } from './components/cms-navigation-page/cms-navigation-page.component'; import { CMSProductListCategoryComponent } from './components/cms-product-list-category/cms-product-list-category.component'; @@ -148,6 +149,14 @@ import { CMS_COMPONENT } from './configurations/injection-keys'; }, multi: true, }, + { + provide: CMS_COMPONENT, + useValue: { + definitionQualifiedName: 'app_sf_base_cm:component.navigation.category.pagelet2-Component', + class: CMSNavigationCategoryComponent, + }, + multi: true, + }, ], }) export class CMSModule {} diff --git a/src/app/shared/cms/components/cms-navigation-category/cms-navigation-category.component.html b/src/app/shared/cms/components/cms-navigation-category/cms-navigation-category.component.html new file mode 100644 index 0000000000..1858cc7e9c --- /dev/null +++ b/src/app/shared/cms/components/cms-navigation-category/cms-navigation-category.component.html @@ -0,0 +1,62 @@ + + + +
  • + + + {{ pagelet.stringParam('DisplayName') }} + + + {{ category.name }} + + + + + + + + + + + + + + + + +
  • +
    diff --git a/src/app/shared/cms/components/cms-navigation-category/cms-navigation-category.component.spec.ts b/src/app/shared/cms/components/cms-navigation-category/cms-navigation-category.component.spec.ts new file mode 100644 index 0000000000..91342b9871 --- /dev/null +++ b/src/app/shared/cms/components/cms-navigation-category/cms-navigation-category.component.spec.ts @@ -0,0 +1,246 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { MockComponent, MockDirective } from 'ng-mocks'; +import { of } from 'rxjs'; +import { anything, instance, mock, when } from 'ts-mockito'; + +import { ServerHtmlDirective } from 'ish-core/directives/server-html.directive'; +import { ShoppingFacade } from 'ish-core/facades/shopping.facade'; +import { createContentPageletView } from 'ish-core/models/content-view/content-view.model'; +import { NavigationCategory } from 'ish-core/models/navigation-category/navigation-category.model'; + +import { CMSNavigationCategoryComponent } from './cms-navigation-category.component'; + +describe('Cms Navigation Category Component', () => { + let component: CMSNavigationCategoryComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let shoppingFacade: ShoppingFacade; + + const pagelet = { + definitionQualifiedName: 'dqn', + id: 'id', + displayName: 'name', + domain: 'domain', + configurationParameters: { + Category: 'A@1', + SubNavigationDepth: 0, + }, + }; + + const categoryTree_0 = { uniqueId: 'A', name: 'Cat A', url: '/cat/A' } as NavigationCategory; + + const categoryTree_1 = { + uniqueId: 'A', + name: 'Cat A', + url: '/cat/A', + children: [ + { uniqueId: 'A_1', name: 'Cat A1', url: '/cat/A.A_1' }, + { uniqueId: 'A_2', name: 'Cat A2', url: '/cat/A.A_2' }, + { uniqueId: 'A_3', name: 'Cat A3', url: '/cat/A.A_3' }, + ], + } as NavigationCategory; + + const categoryTree_2 = { + uniqueId: 'A', + name: 'Cat A', + url: '/cat/A', + children: [ + { + uniqueId: 'A_1', + name: 'Cat A1', + url: '/cat/A.A_1', + children: [{ uniqueId: 'A_1_a', name: 'Cat A1 a', url: '/cat/A.A_1.A_1_a' }], + }, + ], + } as NavigationCategory; + + beforeEach(async () => { + shoppingFacade = mock(ShoppingFacade); + + await TestBed.configureTestingModule({ + imports: [RouterTestingModule], + declarations: [ + CMSNavigationCategoryComponent, + MockComponent(FaIconComponent), + MockDirective(ServerHtmlDirective), + ], + providers: [{ provide: ShoppingFacade, useFactory: () => instance(shoppingFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CMSNavigationCategoryComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + when(shoppingFacade.navigationCategoryTree$(anything(), 0)).thenReturn(of(categoryTree_0)); + when(shoppingFacade.navigationCategoryTree$(anything(), 1)).thenReturn(of(categoryTree_1)); + when(shoppingFacade.navigationCategoryTree$(anything(), 2)).thenReturn(of(categoryTree_2)); + when(shoppingFacade.navigationCategoryTree$(anything(), 3)).thenReturn(of(categoryTree_0)); + }); + + it('should be created', () => { + component.pagelet = createContentPageletView(pagelet); + component.ngOnChanges(); + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + expect(element).toMatchInlineSnapshot(` + + `); + }); + + it('should render an Alternative Display Name and a CSS Class if set', () => { + component.pagelet = createContentPageletView({ + ...pagelet, + configurationParameters: { + ...pagelet.configurationParameters, + DisplayName: 'Navigation Category', + CSSClass: 'nav-cat', + }, + }); + component.ngOnChanges(); + fixture.detectChanges(); + expect(element).toMatchInlineSnapshot(` + + `); + }); + + it('should render Subnavigation HTML if set', () => { + component.pagelet = createContentPageletView({ + ...pagelet, + configurationParameters: { ...pagelet.configurationParameters, SubNavigationHTML: 'Hello Category' }, + }); + component.ngOnChanges(); + fixture.detectChanges(); + expect(element).toMatchInlineSnapshot(` + + `); + }); + + it('should render category tree and Subnavigation HTML if both are set', () => { + component.pagelet = createContentPageletView({ + ...pagelet, + configurationParameters: { + ...pagelet.configurationParameters, + SubNavigationDepth: 2, + SubNavigationHTML: 'Hello Category', + }, + }); + component.ngOnChanges(); + fixture.detectChanges(); + expect(element).toMatchInlineSnapshot(` + + `); + }); + + it('should render a category tree with Subnavigation Depth of 1 if set', () => { + component.pagelet = createContentPageletView({ + ...pagelet, + configurationParameters: { + ...pagelet.configurationParameters, + SubNavigationDepth: 1, + }, + }); + component.ngOnChanges(); + fixture.detectChanges(); + expect(element).toMatchInlineSnapshot(` + + `); + }); + + it('should render a category tree with Subnavigation Depth of 2 if set', () => { + component.pagelet = createContentPageletView({ + ...pagelet, + configurationParameters: { + ...pagelet.configurationParameters, + SubNavigationDepth: 2, + }, + }); + component.ngOnChanges(); + fixture.detectChanges(); + expect(element).toMatchInlineSnapshot(` + + `); + }); + + it('should not render a sub naviagtion if category has no children even if Subnavigation Depth is set', () => { + component.pagelet = createContentPageletView({ + ...pagelet, + configurationParameters: { + ...pagelet.configurationParameters, + SubNavigationDepth: 3, + }, + }); + component.ngOnChanges(); + fixture.detectChanges(); + expect(element).toMatchInlineSnapshot(` + + `); + }); +}); diff --git a/src/app/shared/cms/components/cms-navigation-category/cms-navigation-category.component.ts b/src/app/shared/cms/components/cms-navigation-category/cms-navigation-category.component.ts new file mode 100644 index 0000000000..9196c425b0 --- /dev/null +++ b/src/app/shared/cms/components/cms-navigation-category/cms-navigation-category.component.ts @@ -0,0 +1,63 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { ShoppingFacade } from 'ish-core/facades/shopping.facade'; +import { ContentPageletView } from 'ish-core/models/content-view/content-view.model'; +import { NavigationCategory } from 'ish-core/models/navigation-category/navigation-category.model'; +import { CMSComponent } from 'ish-shared/cms/models/cms-component/cms-component.model'; + +@Component({ + selector: 'ish-cms-navigation-category', + templateUrl: './cms-navigation-category.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CMSNavigationCategoryComponent implements CMSComponent, OnChanges { + @Input({ required: true }) pagelet: ContentPageletView; + + categoryTree$: Observable; + + private openedCategories: string[] = []; + + constructor(private shoppingFacade: ShoppingFacade) {} + + ngOnChanges(): void { + if (this.pagelet?.hasParam('Category')) { + this.categoryTree$ = this.shoppingFacade.navigationCategoryTree$( + this.pagelet.stringParam('Category'), + this.pagelet.numberParam('SubNavigationDepth', 0) + ); + } + } + + showSubMenu(childCount: number) { + return (this.pagelet.hasParam('SubNavigationDepth') && + this.pagelet.numberParam('SubNavigationDepth') > 0 && + childCount) || + this.pagelet.hasParam('SubNavigationHTML') + ? true + : false; + } + + subMenuShow(subMenu: HTMLElement) { + subMenu.classList.add('hover'); + } + + subMenuHide(subMenu: HTMLElement) { + subMenu.classList.remove('hover'); + } + + /** + * Indicate if specific content page is expanded. + */ + isOpened(uniqueId: string): boolean { + return this.openedCategories.includes(uniqueId); + } + + /** + * Toggle content page open state. + */ + toggleOpen(uniqueId: string) { + const index = this.openedCategories.findIndex(id => id === uniqueId); + index > -1 ? this.openedCategories.splice(index, 1) : this.openedCategories.push(uniqueId); + } +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 36708aa294..f9492b9e4e 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -45,6 +45,7 @@ import { CMSDialogComponent } from './cms/components/cms-dialog/cms-dialog.compo import { CMSFreestyleComponent } from './cms/components/cms-freestyle/cms-freestyle.component'; import { CMSImageEnhancedComponent } from './cms/components/cms-image-enhanced/cms-image-enhanced.component'; import { CMSImageComponent } from './cms/components/cms-image/cms-image.component'; +import { CMSNavigationCategoryComponent } from './cms/components/cms-navigation-category/cms-navigation-category.component'; import { CMSNavigationLinkComponent } from './cms/components/cms-navigation-link/cms-navigation-link.component'; import { CMSNavigationPageComponent } from './cms/components/cms-navigation-page/cms-navigation-page.component'; import { CMSProductListCategoryComponent } from './cms/components/cms-product-list-category/cms-product-list-category.component'; @@ -201,6 +202,7 @@ const declaredComponents = [ CMSFreestyleComponent, CMSImageComponent, CMSImageEnhancedComponent, + CMSNavigationCategoryComponent, CMSNavigationLinkComponent, CMSNavigationPageComponent, CMSProductListCategoryComponent,