diff --git a/libs/navigation/driver/in-memory/src/navigation.service.ts b/libs/navigation/driver/in-memory/src/navigation.service.ts index 6ab3fe65e7..7655442d0d 100644 --- a/libs/navigation/driver/in-memory/src/navigation.service.ts +++ b/libs/navigation/driver/in-memory/src/navigation.service.ts @@ -16,6 +16,10 @@ export class DaffInMemoryNavigationService implements DaffNavigationServiceInter constructor(private http: HttpClient) {} + getTree(): Observable { + return this.http.get(this.url); + } + get(navigationId: DaffNavigationTree['id']): Observable { return this.http.get(this.url + navigationId); } diff --git a/libs/navigation/driver/magento/src/config/interface.ts b/libs/navigation/driver/magento/src/config/interface.ts index 2f8e687d09..1b8fa2c073 100644 --- a/libs/navigation/driver/magento/src/config/interface.ts +++ b/libs/navigation/driver/magento/src/config/interface.ts @@ -8,4 +8,10 @@ export interface MagentoNavigationDriverConfig { * Defaults to 3. */ navigationTreeQueryDepth: number; + + /** + * The UID of the root category. + * While this is optional, setting it will prevent an extra driver call during `getTree`. + */ + rootCategoryId?: string; } diff --git a/libs/navigation/driver/magento/src/navigation.service.spec.ts b/libs/navigation/driver/magento/src/navigation.service.spec.ts index c5c20e1005..1c60e22ac6 100644 --- a/libs/navigation/driver/magento/src/navigation.service.spec.ts +++ b/libs/navigation/driver/magento/src/navigation.service.spec.ts @@ -1,10 +1,16 @@ -import { TestBed } from '@angular/core/testing'; +import { + TestBed, + fakeAsync, + flush, + tick, +} from '@angular/core/testing'; import { InMemoryCache } from '@apollo/client/core'; import { addTypenameToDocument } from '@apollo/client/utilities'; import { ApolloTestingModule, ApolloTestingController, APOLLO_TESTING_CACHE, + TestOperation, } from 'apollo-angular/testing'; import { schema } from '@daffodil/driver/magento'; @@ -18,8 +24,12 @@ import { DaffNavigationTreeFactory } from '@daffodil/navigation/testing'; import { GetCategoryTreeResponse } from './models/public_api'; import { DaffMagentoNavigationService } from './navigation.service'; +import { + MagentoNavgiationGetRootCategoryIdResponse, + magentoNavigationGetRootCategoryIdQuery, +} from './queries/get-root-category-id/public_api'; -describe('Driver | Magento | Navigation | NavigationService', () => { +describe('@daffodil/navigation/driver/magento | DaffMagentoNavigationService', () => { let navigationService: DaffMagentoNavigationService; let navigationTreeFactory: DaffNavigationTreeFactory; let controller: ApolloTestingController; @@ -99,4 +109,143 @@ describe('Driver | Magento | Navigation | NavigationService', () => { controller.verify(); }); }); + + describe('getTree', () => { + it('should return an observable single navigation', fakeAsync(() => { + const navigation = navigationTreeFactory.create(); + const response: GetCategoryTreeResponse = { + categoryList: [{ + __typename: 'CategoryTree', + uid: navigation.id, + url_path: navigation.id, + url_suffix: '.html', + name: navigation.name, + include_in_menu: true, + level: 0, + position: 1, + product_count: navigation.total_products, + children_count: navigation.children_count, + children: [], + breadcrumbs: [], + }], + }; + + navigationService.getTree().subscribe((result) => { + expect(result.id).toEqual(navigation.id); + expect(result.name).toEqual(navigation.name); + expect(result.url).toEqual(`/${navigation.id}.html`); + expect(result.total_products).toEqual(navigation.total_products); + expect(result.children_count).toEqual(navigation.children_count); + }); + + const rootIdOp: TestOperation = controller.expectOne(addTypenameToDocument(magentoNavigationGetRootCategoryIdQuery)); + rootIdOp.flushData({ + storeConfig: { + root_category_uid: navigation.id, + }, + }); + + flush(); + tick(); + + const op = controller.expectOne(addTypenameToDocument(daffMagentoGetCategoryTree(queryDepth))); + + expect(op.operation.variables.filters).toEqual({ category_uid: { eq: navigation.id }}); + + op.flush({ + data: response, + }); + flush(); + })); + + afterEach(() => { + controller.verify(); + }); + }); +}); + +describe('@daffodil/navigation/driver/magento | DaffMagentoNavigationService | with root category ID', () => { + let navigationService: DaffMagentoNavigationService; + let navigationTreeFactory: DaffNavigationTreeFactory; + let controller: ApolloTestingController; + + const queryDepth = 1; + const categoryId = 'categoryId'; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + ApolloTestingModule, + ], + providers: [ + DaffMagentoNavigationService, + { provide: DaffNavigationTransformer, useExisting: DaffMagentoNavigationTransformerService }, + provideMagentoNavigationDriverConfig({ + navigationTreeQueryDepth: queryDepth, + rootCategoryId: categoryId, + }), + { + provide: APOLLO_TESTING_CACHE, + useValue: new InMemoryCache({ + addTypename: true, + possibleTypes: schema.possibleTypes, + }), + }, + ], + }); + + controller = TestBed.inject(ApolloTestingController); + + navigationService = TestBed.inject(DaffMagentoNavigationService); + navigationTreeFactory = TestBed.inject(DaffNavigationTreeFactory); + }); + + it('should be created', () => { + expect(navigationService).toBeTruthy(); + }); + + describe('getTree', () => { + it('should return an observable single navigation', done => { + const navigation = navigationTreeFactory.create({ + id: categoryId, + }); + const response: GetCategoryTreeResponse = { + categoryList: [{ + __typename: 'CategoryTree', + uid: navigation.id, + url_path: navigation.id, + url_suffix: '.html', + name: navigation.name, + include_in_menu: true, + level: 0, + position: 1, + product_count: navigation.total_products, + children_count: navigation.children_count, + children: [], + breadcrumbs: [], + }], + }; + + navigationService.getTree().subscribe((result) => { + expect(result.id).toEqual(navigation.id); + expect(result.name).toEqual(navigation.name); + expect(result.url).toEqual(`/${navigation.id}.html`); + expect(result.total_products).toEqual(navigation.total_products); + expect(result.children_count).toEqual(navigation.children_count); + done(); + }); + + const op = controller.expectOne(addTypenameToDocument(daffMagentoGetCategoryTree(queryDepth))); + + expect(op.operation.variables.filters).toEqual({ category_uid: { eq: navigation.id }}); + + op.flush({ + data: response, + }); + }); + + afterEach(() => { + controller.verify(); + }); + }); }); diff --git a/libs/navigation/driver/magento/src/navigation.service.ts b/libs/navigation/driver/magento/src/navigation.service.ts index e862b1131b..fb2e2b2c7b 100644 --- a/libs/navigation/driver/magento/src/navigation.service.ts +++ b/libs/navigation/driver/magento/src/navigation.service.ts @@ -3,8 +3,14 @@ import { Inject, } from '@angular/core'; import { Apollo } from 'apollo-angular'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { + Observable, + of, +} from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; import { DaffNavigationTree } from '@daffodil/navigation'; import { @@ -19,6 +25,7 @@ import { } from './config/public_api'; import { GetCategoryTreeResponse } from './models/get-category-tree-response'; import { daffMagentoGetCategoryTree } from './queries/get-category-tree'; +import { magentoNavigationGetRootCategoryIdQuery } from './queries/get-root-category-id/public_api'; /** * @inheritdoc @@ -34,6 +41,20 @@ export class DaffMagentoNavigationService implements DaffNavigationServiceInterf @Inject(MAGENTO_NAVIGATION_DRIVER_CONFIG) private config: MagentoNavigationDriverConfig, ) {} + getTree(): Observable { + const rootCategoryId = this.config.rootCategoryId + ? of(this.config.rootCategoryId) + : this.apollo.query({ + query: magentoNavigationGetRootCategoryIdQuery, + }).pipe( + map(({ data }) => data.storeConfig.root_category_uid), + ); + + return rootCategoryId.pipe( + switchMap((id) => this.get(id)), + ); + } + get(categoryId: string): Observable { return this.apollo.query({ query: daffMagentoGetCategoryTree(this.config.navigationTreeQueryDepth), diff --git a/libs/navigation/driver/magento/src/queries/fragments/category-node.ts b/libs/navigation/driver/magento/src/queries/fragments/category-node.ts index 09ace6680a..a1b4f60a20 100644 --- a/libs/navigation/driver/magento/src/queries/fragments/category-node.ts +++ b/libs/navigation/driver/magento/src/queries/fragments/category-node.ts @@ -2,42 +2,40 @@ import { gql } from 'apollo-angular'; import { DocumentNode } from 'graphql'; -/** - * A category tree fragment with no nested children. - */ -const categoryNodeFragment = ` - uid - url_path - url_suffix - level - name - include_in_menu - breadcrumbs { - category_uid - category_name - category_level - category_url_path - } - position - product_count -`; +const CATEGORY_NODE_FRAGMENT_NAME = 'categoryNode'; /** * Generates a category tree fragment with the specified number of nested child category trees. * * @param depth The maximum depth to which category children should be added to the fragment. */ -//todo: use nested fragments when this bug is fixed: https://github.com/magento/magento2/issues/31086 export function getCategoryNodeFragment(depth: number = 3): DocumentNode { const fragmentBody = new Array(depth).fill(null).reduce(acc => ` - ${categoryNodeFragment} + ...${CATEGORY_NODE_FRAGMENT_NAME} children_count children { ${acc} } - `, categoryNodeFragment); + `, `...${CATEGORY_NODE_FRAGMENT_NAME}`); return gql` + fragment ${CATEGORY_NODE_FRAGMENT_NAME} on CategoryTree { + uid + url_path + url_suffix + level + name + include_in_menu + breadcrumbs { + category_uid + category_name + category_level + category_url_path + } + position + product_count + } + fragment recursiveCategoryNode on CategoryTree { ${fragmentBody} } diff --git a/libs/navigation/driver/magento/src/queries/get-root-category-id/public_api.ts b/libs/navigation/driver/magento/src/queries/get-root-category-id/public_api.ts new file mode 100644 index 0000000000..8bb47bb359 --- /dev/null +++ b/libs/navigation/driver/magento/src/queries/get-root-category-id/public_api.ts @@ -0,0 +1,2 @@ +export * from './query'; +export * from './response.type'; diff --git a/libs/navigation/driver/magento/src/queries/get-root-category-id/query.ts b/libs/navigation/driver/magento/src/queries/get-root-category-id/query.ts new file mode 100644 index 0000000000..8c60b5a343 --- /dev/null +++ b/libs/navigation/driver/magento/src/queries/get-root-category-id/query.ts @@ -0,0 +1,18 @@ +import { gql } from 'apollo-angular'; + +import { MagentoNavgiationGetRootCategoryIdResponse } from './response.type'; + +export const MAGENTO_NAVIGATION_GET_ROOT_CATEGORY_ID_QUERY_NAME = 'MagentoNavigationGetRootCategoryId'; + +/** + * Generates a category tree query with the specified number of nested child category tree fragments. + * + * @param depth The maximum depth to which category children should be added to the fragment. + */ +export const magentoNavigationGetRootCategoryIdQuery = gql` + query ${MAGENTO_NAVIGATION_GET_ROOT_CATEGORY_ID_QUERY_NAME} { + storeConfig { + root_category_uid + } + } +`; diff --git a/libs/navigation/driver/magento/src/queries/get-root-category-id/response.type.ts b/libs/navigation/driver/magento/src/queries/get-root-category-id/response.type.ts new file mode 100644 index 0000000000..79800b2b0f --- /dev/null +++ b/libs/navigation/driver/magento/src/queries/get-root-category-id/response.type.ts @@ -0,0 +1,6 @@ +export interface MagentoNavgiationGetRootCategoryIdResponse { + __typename: string; + storeConfig: { + root_category_uid: string; + }; +} diff --git a/libs/navigation/driver/src/interfaces/navigation-service.interface.ts b/libs/navigation/driver/src/interfaces/navigation-service.interface.ts index 7915c1efd2..b8539f250f 100644 --- a/libs/navigation/driver/src/interfaces/navigation-service.interface.ts +++ b/libs/navigation/driver/src/interfaces/navigation-service.interface.ts @@ -4,7 +4,15 @@ import { Observable } from 'rxjs'; import { DaffGenericNavigationTree } from '@daffodil/navigation'; export interface DaffNavigationServiceInterface> { - get(categoryId: T['id']): Observable; + /** + * Requests a specific navigation item by ID. + */ + get(id: T['id']): Observable; + + /** + * Requests the entire top-level navigation tree. + */ + getTree(): Observable; } export const DaffNavigationDriver = diff --git a/libs/navigation/driver/testing/src/navigation.service.ts b/libs/navigation/driver/testing/src/navigation.service.ts index c9d7f3eefd..3123fdacba 100644 --- a/libs/navigation/driver/testing/src/navigation.service.ts +++ b/libs/navigation/driver/testing/src/navigation.service.ts @@ -15,9 +15,13 @@ import { DaffNavigationTreeFactory } from '@daffodil/navigation/testing'; providedIn: 'root', }) export class DaffTestingNavigationService implements DaffNavigationServiceInterface { - constructor( - private navigationTreeFactory: DaffNavigationTreeFactory) {} + private navigationTreeFactory: DaffNavigationTreeFactory, + ) {} + + getTree(): Observable { + return of(this.navigationTreeFactory.create()); + } get(navigationTreeId: string): Observable { return of(this.navigationTreeFactory.create()); diff --git a/libs/navigation/state/src/actions/navigation.actions.ts b/libs/navigation/state/src/actions/navigation.actions.ts index 3bc99be0a2..28448aed50 100644 --- a/libs/navigation/state/src/actions/navigation.actions.ts +++ b/libs/navigation/state/src/actions/navigation.actions.ts @@ -12,7 +12,7 @@ export enum DaffNavigationActionTypes { export class DaffNavigationLoad implements Action { readonly type = DaffNavigationActionTypes.NavigationLoadAction; - constructor(public payload: string) { } + constructor(public payload?: string) { } } export class DaffNavigationLoadSuccess> implements Action { diff --git a/libs/navigation/state/src/effects/navigation.effects.spec.ts b/libs/navigation/state/src/effects/navigation.effects.spec.ts index 1b6bdfcb1e..7e9f60ac14 100644 --- a/libs/navigation/state/src/effects/navigation.effects.spec.ts +++ b/libs/navigation/state/src/effects/navigation.effects.spec.ts @@ -14,7 +14,7 @@ import { DaffNavigationDriver, DaffNavigationServiceInterface, } from '@daffodil/navigation/driver'; -import { DaffTestingNavigationService } from '@daffodil/navigation/driver/testing'; +import { DaffNavigationTestingDriverModule } from '@daffodil/navigation/driver/testing'; import { DaffNavigationLoad, DaffNavigationLoadSuccess, @@ -31,19 +31,16 @@ describe('DaffNavigationEffects', () => { let daffNavigationDriver: DaffNavigationServiceInterface; let navigationTreeFactory: DaffNavigationTreeFactory; - let navigationId; + let navigationId: DaffNavigationTree['id']; beforeEach(() => { - navigationId = 'navigation id'; - TestBed.configureTestingModule({ + imports: [ + DaffNavigationTestingDriverModule.forRoot(), + ], providers: [ DaffNavigationEffects, provideMockActions(() => actions$), - { - provide: DaffNavigationDriver, - useValue: new DaffTestingNavigationService(new DaffNavigationTreeFactory()), - }, ], }); @@ -53,19 +50,59 @@ describe('DaffNavigationEffects', () => { daffNavigationDriver = TestBed.inject(DaffNavigationDriver); mockNavigation = navigationTreeFactory.create(); + navigationId = mockNavigation.id; }); it('should be created', () => { expect(effects).toBeTruthy(); }); - describe('when NavigationLoadAction is triggered', () => { - + describe('when NavigationLoadAction is triggered without a payload', () => { let expected; - const navigationLoadAction = new DaffNavigationLoad(navigationId); + let navigationLoadAction: DaffNavigationLoad; + + beforeEach(() => { + navigationLoadAction = new DaffNavigationLoad(); + }); describe('and the call to NavigationService is successful', () => { + beforeEach(() => { + spyOn(daffNavigationDriver, 'getTree').and.returnValue(of(mockNavigation)); + const navigationLoadSuccessAction = new DaffNavigationLoadSuccess(mockNavigation); + actions$ = hot('--a', { a: navigationLoadAction }); + expected = cold('--b', { b: navigationLoadSuccessAction }); + }); + + it('should dispatch a NavigationLoadSuccess action', () => { + expect(effects.loadNavigation$).toBeObservable(expected); + }); + }); + + describe('and the call to NavigationService fails', () => { + beforeEach(() => { + const error = { code: 'code', recoverable: false, message: 'error message' }; + const response = cold('#', {}, error); + spyOn(daffNavigationDriver, 'getTree').and.returnValue(response); + const navigationLoadFailureAction = new DaffNavigationLoadFailure(error); + actions$ = hot('--a', { a: navigationLoadAction }); + expected = cold('--b', { b: navigationLoadFailureAction }); + }); + + it('should dispatch a NavigationLoadFailure action', () => { + expect(effects.loadNavigation$).toBeObservable(expected); + }); + }); + }); + + describe('when NavigationLoadAction is triggered with a payload', () => { + let expected; + let navigationLoadAction: DaffNavigationLoad; + beforeEach(() => { + navigationLoadAction = new DaffNavigationLoad(navigationId); + }); + + describe('and the call to NavigationService is successful', () => { beforeEach(() => { spyOn(daffNavigationDriver, 'get').and.returnValue(of(mockNavigation)); const navigationLoadSuccessAction = new DaffNavigationLoadSuccess(mockNavigation); @@ -79,7 +116,6 @@ describe('DaffNavigationEffects', () => { }); describe('and the call to NavigationService fails', () => { - beforeEach(() => { const error = { code: 'code', recoverable: false, message: 'error message' }; const response = cold('#', {}, error); diff --git a/libs/navigation/state/src/effects/navigation.effects.ts b/libs/navigation/state/src/effects/navigation.effects.ts index f821d1923e..16835958f6 100644 --- a/libs/navigation/state/src/effects/navigation.effects.ts +++ b/libs/navigation/state/src/effects/navigation.effects.ts @@ -35,18 +35,18 @@ import { DAFF_NAVIGATION_ERROR_MATCHER } from '../injection-tokens/public_api'; @Injectable() export class DaffNavigationEffects> { - constructor( private actions$: Actions, @Inject(DaffNavigationDriver) private driver: DaffNavigationServiceInterface, @Inject(DAFF_NAVIGATION_ERROR_MATCHER) private errorMatcher: ErrorTransformer, ) {} - loadNavigation$: Observable = createEffect(() => this.actions$.pipe( ofType(DaffNavigationActionTypes.NavigationLoadAction), switchMap((action: DaffNavigationLoad) => - this.driver.get(action.payload) + (action.payload + ? this.driver.get(action.payload) + : this.driver.getTree()) .pipe( map((resp) => new DaffNavigationLoadSuccess(resp)), catchError((error: DaffError) => of(new DaffNavigationLoadFailure(this.errorMatcher(error)))), diff --git a/libs/navigation/state/src/reducers/navigation-reducers.ts b/libs/navigation/state/src/reducers/navigation-reducers.ts index e011441b35..b2f9a9a98f 100644 --- a/libs/navigation/state/src/reducers/navigation-reducers.ts +++ b/libs/navigation/state/src/reducers/navigation-reducers.ts @@ -7,4 +7,5 @@ import { DaffNavigationReducersState } from './navigation-reducers.interface'; export const daffNavigationReducers: ActionReducerMap> = { navigation: daffNavigationReducer, + // TODO: add entity state };