From 72b3f9eccb8b361b7c88ff51c19bd684934051ae Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Sun, 21 Jun 2020 00:50:42 +0300 Subject: [PATCH] feat: update route visibility on app config change --- .../components/dynamic-layout.component.ts | 13 ++-- .../packages/core/src/lib/models/common.ts | 6 +- .../core/src/lib/services/routes.service.ts | 52 ++++++++-------- .../tests/dynamic-layout.component.spec.ts | 16 ++++- .../core/src/lib/tests/routes.service.spec.ts | 60 ++++++++++++++----- 5 files changed, 97 insertions(+), 50 deletions(-) diff --git a/npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts b/npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts index 5455187086..6c7de6e6ef 100644 --- a/npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts +++ b/npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts @@ -23,13 +23,10 @@ import { TreeNode } from '../utils/tree-utils'; export class DynamicLayoutComponent implements OnDestroy { layout: Type; - constructor( - injector: Injector, - private route: ActivatedRoute, - private routes: RoutesService, - private store: Store, - ) { + constructor(injector: Injector, private store: Store) { + const route = injector.get(ActivatedRoute); const router = injector.get(Router); + const routes = injector.get(RoutesService); const layouts = { application: this.getComponent('Theme.ApplicationLayoutComponent'), account: this.getComponent('Theme.AccountLayoutComponent'), @@ -38,11 +35,11 @@ export class DynamicLayoutComponent implements OnDestroy { router.events.pipe(takeUntilDestroy(this)).subscribe(event => { if (event instanceof NavigationEnd) { - let expectedLayout = (this.route.snapshot.data || {}).layout; + let expectedLayout = (route.snapshot.data || {}).layout; const path = getRoutePath(router); if (!expectedLayout) { - let node = { parent: this.routes.search({ path }) } as TreeNode; + let node = { parent: routes.search({ path }) } as TreeNode; while (node.parent) { node = node.parent; diff --git a/npm/ng-packs/packages/core/src/lib/models/common.ts b/npm/ng-packs/packages/core/src/lib/models/common.ts index 410c133b07..930265020c 100644 --- a/npm/ng-packs/packages/core/src/lib/models/common.ts +++ b/npm/ng-packs/packages/core/src/lib/models/common.ts @@ -30,7 +30,7 @@ export namespace ABP { maxResultCount?: number; } - export interface Node { + export interface Nav { name: string; parentName?: string; requiredPolicy?: string; @@ -38,13 +38,13 @@ export namespace ABP { invisible?: boolean; } - export interface Route extends Node { + export interface Route extends Nav { path: string; layout?: eLayoutType; iconClass?: string; } - export interface Tab extends Node { + export interface Tab extends Nav { component: Type; } diff --git a/npm/ng-packs/packages/core/src/lib/services/routes.service.ts b/npm/ng-packs/packages/core/src/lib/services/routes.service.ts index 4b9f3e7899..083f10c8a4 100644 --- a/npm/ng-packs/packages/core/src/lib/services/routes.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/routes.service.ts @@ -1,8 +1,10 @@ import { Injectable } from '@angular/core'; -import { Store } from '@ngxs/store'; +import { Actions, ofActionSuccessful, Store } from '@ngxs/store'; import { BehaviorSubject, Observable } from 'rxjs'; +import { GetAppConfiguration } from '../actions'; import { ABP } from '../models/common'; import { ConfigState } from '../states/config.state'; +import { takeUntilDestroy } from '../utils'; import { pushValueTo } from '../utils/array-utils'; import { BaseTreeNode, createTreeFromList, TreeNode } from '../utils/tree-utils'; @@ -88,6 +90,10 @@ export abstract class AbstractTreeService { return this.publish(flatItems, visibleItems); } + refresh(): T[] { + return this.add([]); + } + remove(identifiers: string[]): T[] { const set = new Set(); identifiers.forEach(id => set.add(id)); @@ -98,7 +104,7 @@ export abstract class AbstractTreeService { return this.publish(flatItems, visibleItems); } - search(params: Partial, tree = this.tree): TreeNode { + search(params: Partial, tree = this.tree): TreeNode | null { const searchKeys = Object.keys(params); return tree.reduce( @@ -107,38 +113,36 @@ export abstract class AbstractTreeService { ? acc : searchKeys.every(key => node[key] === params[key]) ? node - : node.children - ? this.search(params, node.children) - : acc, + : this.search(params, node.children), null, ); } } -@Injectable({ - providedIn: 'root', -}) -export class RoutesService extends AbstractTreeService { - readonly id = 'name'; - readonly parentId = 'parentName'; - readonly hide = (item: ABP.Route) => item.invisible; - readonly sort = (a: ABP.Route, b: ABP.Route) => a.order - b.order; -} - -@Injectable({ - providedIn: 'root', -}) -export class SettingTabsService extends AbstractTreeService { +export abstract class AbstractNavTreeService extends AbstractTreeService { readonly id = 'name'; readonly parentId = 'parentName'; - readonly hide = (setting: ABP.Tab) => setting.invisible || !this.isGranted(setting); - readonly sort = (a: ABP.Tab, b: ABP.Tab) => a.order - b.order; + readonly hide = (item: T) => item.invisible || !this.isGranted(item); + readonly sort = (a: T, b: T) => a.order - b.order; - constructor(private store: Store) { + constructor(protected actions: Actions, protected store: Store) { super(); + + this.actions + .pipe(takeUntilDestroy(this), ofActionSuccessful(GetAppConfiguration)) + .subscribe(() => this.refresh()); } - private isGranted(setting: ABP.Tab): boolean { - return this.store.selectSnapshot(ConfigState.getGrantedPolicy(setting.requiredPolicy)); + protected isGranted({ requiredPolicy }: T): boolean { + return this.store.selectSnapshot(ConfigState.getGrantedPolicy(requiredPolicy)); } + + /* istanbul ignore next */ + ngOnDestroy() {} } + +@Injectable({ providedIn: 'root' }) +export class RoutesService extends AbstractNavTreeService {} + +@Injectable({ providedIn: 'root' }) +export class SettingTabsService extends AbstractNavTreeService {} diff --git a/npm/ng-packs/packages/core/src/lib/tests/dynamic-layout.component.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/dynamic-layout.component.spec.ts index 89a6a683b9..216df7b681 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/dynamic-layout.component.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/dynamic-layout.component.spec.ts @@ -2,7 +2,8 @@ import { HttpClient } from '@angular/common/http'; import { Component, NgModule } from '@angular/core'; import { ActivatedRoute, RouterModule } from '@angular/router'; import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/jest'; -import { NgxsModule, Store } from '@ngxs/store'; +import { Actions, NgxsModule, Store } from '@ngxs/store'; +import { NEVER } from 'rxjs'; import { DynamicLayoutComponent, RouterOutletComponent } from '../components'; import { eLayoutType } from '../enums/common'; import { ABP } from '../models'; @@ -97,11 +98,24 @@ const storeData = { }; describe('DynamicLayoutComponent', () => { + const mockActions: Actions = NEVER; + const mockStore = ({ + selectSnapshot() { + return true; + }, + } as unknown) as Store; + const createComponent = createRoutingFactory({ component: RouterOutletComponent, stubsEnabled: false, declarations: [DummyComponent, DynamicLayoutComponent], mocks: [ApplicationConfigurationService, HttpClient], + providers: [ + { + provide: RoutesService, + useFactory: () => new RoutesService(mockActions, mockStore), + }, + ], imports: [RouterModule, DummyLayoutModule, NgxsModule.forRoot([ReplaceableComponentsState])], routes: [ { path: '', component: RouterOutletComponent }, diff --git a/npm/ng-packs/packages/core/src/lib/tests/routes.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/routes.service.spec.ts index 2392a86183..5b15653e91 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/routes.service.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/routes.service.spec.ts @@ -1,17 +1,31 @@ +import { Store } from '@ngxs/store'; +import { Subject } from 'rxjs'; import { take } from 'rxjs/operators'; +import { GetAppConfiguration } from '../actions'; import { RoutesService } from '../services'; -const routes = [ - { path: '/foo', name: 'foo' }, - { path: '/foo/bar', name: 'bar', parentName: 'foo', invisible: true, order: 2 }, - { path: '/foo/bar/baz', name: 'baz', parentName: 'bar', order: 1 }, - { path: '/foo/x', name: 'x', parentName: 'foo', order: 1 }, -]; - describe('Routes Service', () => { + let service: RoutesService; + const routes = [ + { path: '/foo', name: 'foo' }, + { path: '/foo/bar', name: 'bar', parentName: 'foo', invisible: true, order: 2 }, + { path: '/foo/bar/baz', name: 'baz', parentName: 'bar', order: 1 }, + { path: '/foo/x', name: 'x', parentName: 'foo', order: 1 }, + ]; + + const mockActions = new Subject(); + const mockStore = ({ + selectSnapshot() { + return true; + }, + } as unknown) as Store; + + beforeEach(() => { + service = new RoutesService(mockActions, mockStore); + }); + describe('#add', () => { it('should add given routes as flat$, tree$, and visible$', async () => { - const service = new RoutesService(); service.add(routes); const flat = await service.flat$.pipe(take(1)).toPromise(); @@ -40,7 +54,6 @@ describe('Routes Service', () => { describe('#remove', () => { it('should remove routes based on given routeNames', () => { - const service = new RoutesService(); service.add(routes); service.remove(['bar']); @@ -66,9 +79,9 @@ describe('Routes Service', () => { describe('#patch', () => { it('should patch propeties of routes based on given routeNames', () => { - const service = new RoutesService(); + service['isGranted'] = jest.fn(route => route.requiredPolicy !== 'X'); service.add(routes); - service.patch('x', { invisible: true }); + service.patch('x', { requiredPolicy: 'X' }); const flat = service.flat; const tree = service.tree; @@ -93,21 +106,40 @@ describe('Routes Service', () => { }); it('should return false when route name is not found', () => { - const service = new RoutesService(); service.add(routes); const result = service.patch('A man has no name.', { invisible: true }); expect(result).toBe(false); }); }); + describe('#refresh', () => { + it('should call add once with empty array', () => { + const add = jest.spyOn(service, 'add'); + service.refresh(); + expect(add).toHaveBeenCalledTimes(1); + expect(add).toHaveBeenCalledWith([]); + }); + + it('should be called upon successful GetAppConfiguration action', () => { + const refresh = jest.spyOn(service, 'refresh'); + mockActions.next({ action: new GetAppConfiguration(), status: 'SUCCESSFUL' }); + expect(refresh).toHaveBeenCalledTimes(1); + }); + }); + describe('#search', () => { - it('should return node found when route name is not found', () => { - const service = new RoutesService(); + it('should return node found based on query', () => { service.add(routes); const result = service.search({ invisible: true }); expect(result.name).toBe('bar'); expect(result.children.length).toBe(1); expect(result.children[0].name).toBe('baz'); }); + + it('should return null when query is not found', () => { + service.add(routes); + const result = service.search({ requiredPolicy: 'X' }); + expect(result).toBe(null); + }); }); });