From c1ff0e1c0779f76965be2e6ffe581b5dfaa39fb2 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Fri, 19 Jun 2020 15:32:16 +0300 Subject: [PATCH 01/16] refactor: remove Nav type --- npm/ng-packs/packages/core/src/lib/models/common.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) 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 79e3d3f52b..410c133b07 100644 --- a/npm/ng-packs/packages/core/src/lib/models/common.ts +++ b/npm/ng-packs/packages/core/src/lib/models/common.ts @@ -33,21 +33,18 @@ export namespace ABP { export interface Node { name: string; parentName?: string; + requiredPolicy?: string; order?: number; invisible?: boolean; } - export interface Nav extends Node { + export interface Route extends Node { path: string; - requiredPolicy?: string; - } - - export interface Route extends Nav { layout?: eLayoutType; iconClass?: string; } - export interface Tab extends Nav { + export interface Tab extends Node { component: Type; } From 72b3f9eccb8b361b7c88ff51c19bd684934051ae Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Sun, 21 Jun 2020 00:50:42 +0300 Subject: [PATCH 02/16] 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); + }); }); }); From ff3a60e68ef0c4a6fb48369ec39ee879cc05f251 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Sun, 21 Jun 2020 01:59:23 +0300 Subject: [PATCH 03/16] feat: add routes handler to catch routing config --- .../packages/core/src/lib/core.module.ts | 7 +++ .../packages/core/src/lib/handlers/index.ts | 1 + .../core/src/lib/handlers/routes.handler.ts | 25 ++++++++ .../core/src/lib/tests/routes.handler.spec.ts | 63 +++++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 npm/ng-packs/packages/core/src/lib/handlers/routes.handler.ts create mode 100644 npm/ng-packs/packages/core/src/lib/tests/routes.handler.spec.ts diff --git a/npm/ng-packs/packages/core/src/lib/core.module.ts b/npm/ng-packs/packages/core/src/lib/core.module.ts index 72aeacb29d..5023876a4d 100644 --- a/npm/ng-packs/packages/core/src/lib/core.module.ts +++ b/npm/ng-packs/packages/core/src/lib/core.module.ts @@ -21,6 +21,7 @@ import { PermissionDirective } from './directives/permission.directive'; import { ReplaceableTemplateDirective } from './directives/replaceable-template.directive'; import { StopPropagationDirective } from './directives/stop-propagation.directive'; import { VisibilityDirective } from './directives/visibility.directive'; +import { RoutesHandler } from './handlers/routes.handler'; import { ApiInterceptor } from './interceptors/api.interceptor'; import { LocalizationModule } from './localization.module'; import { ABP } from './models/common'; @@ -196,6 +197,12 @@ export class CoreModule { deps: [LocalizationService], useFactory: noop, }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [RoutesHandler], + useFactory: noop, + }, { provide: OAuthStorage, useFactory: storageFactory }, ], }; diff --git a/npm/ng-packs/packages/core/src/lib/handlers/index.ts b/npm/ng-packs/packages/core/src/lib/handlers/index.ts index e69de29bb2..b666218d93 100644 --- a/npm/ng-packs/packages/core/src/lib/handlers/index.ts +++ b/npm/ng-packs/packages/core/src/lib/handlers/index.ts @@ -0,0 +1 @@ +export * from './routes.handler'; diff --git a/npm/ng-packs/packages/core/src/lib/handlers/routes.handler.ts b/npm/ng-packs/packages/core/src/lib/handlers/routes.handler.ts new file mode 100644 index 0000000000..a5446c7101 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/handlers/routes.handler.ts @@ -0,0 +1,25 @@ +import { Injectable, Optional } from '@angular/core'; +import { Router } from '@angular/router'; +import { RoutesService } from '../services/routes.service'; + +@Injectable({ + providedIn: 'root', +}) +export class RoutesHandler { + constructor(private routes: RoutesService, @Optional() private router: Router) { + this.addRoutes(); + } + + addRoutes() { + this.router?.config.forEach(({ path, data }) => { + if (!data) return; + + if (data.route) { + this.routes.add([{ path: '/' + path, ...data.route }]); + return; + } + + if (data.routes) this.routes.add(data.routes); + }); + } +} diff --git a/npm/ng-packs/packages/core/src/lib/tests/routes.handler.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/routes.handler.spec.ts new file mode 100644 index 0000000000..e1e32d9221 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/routes.handler.spec.ts @@ -0,0 +1,63 @@ +import { Router } from '@angular/router'; +import { RoutesHandler } from '../handlers'; +import { RoutesService } from '../services'; + +describe('Routes Handler', () => { + describe('#add', () => { + it('should add routes from router config', () => { + const config = [ + { path: 'x' }, + { path: '', data: { route: { name: 'Foo' } } }, + { path: 'bar', data: { route: { name: 'Bar' } } }, + { data: { routes: [{ path: '/baz', name: 'Baz' }] } }, + ]; + const foo = [{ path: '/', name: 'Foo' }]; + const bar = [{ path: '/bar', name: 'Bar' }]; + const baz = [{ path: '/baz', name: 'Baz' }]; + + const routes = []; + const add = jest.fn(routes.push.bind(routes)); + const mockRoutesService = ({ add } as unknown) as RoutesService; + const mockRouter = ({ config } as unknown) as Router; + + const handler = new RoutesHandler(mockRoutesService, mockRouter); + + expect(add).toHaveBeenCalledTimes(3); + expect(routes).toEqual([foo, bar, baz]); + }); + }); + describe('#add', () => { + it('should add routes from router config', () => { + const config = [ + { path: 'x' }, + { path: 'y', data: {} }, + { path: '', data: { route: { name: 'Foo' } } }, + { path: 'bar', data: { route: { name: 'Bar' } } }, + { data: { routes: [{ path: '/baz', name: 'Baz' }] } }, + ]; + const foo = [{ path: '/', name: 'Foo' }]; + const bar = [{ path: '/bar', name: 'Bar' }]; + const baz = [{ path: '/baz', name: 'Baz' }]; + + const routes = []; + const add = jest.fn(routes.push.bind(routes)); + const mockRoutesService = ({ add } as unknown) as RoutesService; + const mockRouter = ({ config } as unknown) as Router; + + const handler = new RoutesHandler(mockRoutesService, mockRouter); + + expect(add).toHaveBeenCalledTimes(3); + expect(routes).toEqual([foo, bar, baz]); + }); + + it('should not add routes when there is no router', () => { + const routes = []; + const add = jest.fn(routes.push.bind(routes)); + const mockRoutesService = ({ add } as unknown) as RoutesService; + + const handler = new RoutesHandler(mockRoutesService, null); + + expect(add).not.toHaveBeenCalled(); + }); + }); +}); From b4a47df4cf59d358026f4a194a09080488ae2fa0 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Sun, 21 Jun 2020 02:44:45 +0300 Subject: [PATCH 04/16] feat: avoid breaking change in route handler --- .../core/src/lib/handlers/routes.handler.ts | 30 +++++++++++++++---- .../core/src/lib/tests/routes.handler.spec.ts | 27 ++--------------- 2 files changed, 27 insertions(+), 30 deletions(-) diff --git a/npm/ng-packs/packages/core/src/lib/handlers/routes.handler.ts b/npm/ng-packs/packages/core/src/lib/handlers/routes.handler.ts index a5446c7101..10f95fa0c4 100644 --- a/npm/ng-packs/packages/core/src/lib/handlers/routes.handler.ts +++ b/npm/ng-packs/packages/core/src/lib/handlers/routes.handler.ts @@ -1,5 +1,6 @@ import { Injectable, Optional } from '@angular/core'; import { Router } from '@angular/router'; +import { ABP } from '../models'; import { RoutesService } from '../services/routes.service'; @Injectable({ @@ -11,15 +12,34 @@ export class RoutesHandler { } addRoutes() { - this.router?.config.forEach(({ path, data }) => { - if (!data) return; + this.router?.config.forEach(({ path = '', data }) => { + if (!data?.routes) return; - if (data.route) { - this.routes.add([{ path: '/' + path, ...data.route }]); + if (Array.isArray(data.routes)) { + this.routes.add(data.routes); return; } - if (data.routes) this.routes.add(data.routes); + const routes = flatRoutes([{ ...data.routes, path }], { path: '' }); + this.routes.add(routes); }); } } + +function flatRoutes(routes: RouteDef[], parent: any) { + if (!routes) return []; + + return routes.reduce((acc, route) => { + const current = { + ...route, + parentName: parent.name, + path: parent.path + '/' + route.path, + }; + + acc.push(current, ...flatRoutes(current.children, current)); + + return acc; + }, []); +} + +type RouteDef = ABP.Route & { children: RouteDef[] }; diff --git a/npm/ng-packs/packages/core/src/lib/tests/routes.handler.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/routes.handler.spec.ts index e1e32d9221..777df39c60 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/routes.handler.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/routes.handler.spec.ts @@ -3,36 +3,13 @@ import { RoutesHandler } from '../handlers'; import { RoutesService } from '../services'; describe('Routes Handler', () => { - describe('#add', () => { - it('should add routes from router config', () => { - const config = [ - { path: 'x' }, - { path: '', data: { route: { name: 'Foo' } } }, - { path: 'bar', data: { route: { name: 'Bar' } } }, - { data: { routes: [{ path: '/baz', name: 'Baz' }] } }, - ]; - const foo = [{ path: '/', name: 'Foo' }]; - const bar = [{ path: '/bar', name: 'Bar' }]; - const baz = [{ path: '/baz', name: 'Baz' }]; - - const routes = []; - const add = jest.fn(routes.push.bind(routes)); - const mockRoutesService = ({ add } as unknown) as RoutesService; - const mockRouter = ({ config } as unknown) as Router; - - const handler = new RoutesHandler(mockRoutesService, mockRouter); - - expect(add).toHaveBeenCalledTimes(3); - expect(routes).toEqual([foo, bar, baz]); - }); - }); describe('#add', () => { it('should add routes from router config', () => { const config = [ { path: 'x' }, { path: 'y', data: {} }, - { path: '', data: { route: { name: 'Foo' } } }, - { path: 'bar', data: { route: { name: 'Bar' } } }, + { path: '', data: { routes: { name: 'Foo' } } }, + { path: 'bar', data: { routes: { name: 'Bar' } } }, { data: { routes: [{ path: '/baz', name: 'Baz' }] } }, ]; const foo = [{ path: '/', name: 'Foo' }]; From 94b4d1073b46800df67a87551559b5ae8f81c84e Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Sun, 21 Jun 2020 03:04:23 +0300 Subject: [PATCH 05/16] feat: add find method to abstract tree service --- .../core/src/lib/services/routes.service.ts | 7 +++++++ .../core/src/lib/tests/routes.service.spec.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) 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 083f10c8a4..bf3917f5cd 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 @@ -77,6 +77,13 @@ export abstract class AbstractTreeService { return this.publish(flatItems, visibleItems); } + find(predicate: (item: TreeNode) => boolean, tree = this.tree) { + return tree.reduce( + (acc, node) => (acc ? acc : predicate(node) ? node : this.find(predicate, node.children)), + null, + ); + } + patch(identifier: string, props: Partial): T[] | false { const flatItems = this._flat$.value; const index = flatItems.findIndex(item => item[this.id] === identifier); 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 5b15653e91..ffb5c8b2f0 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 @@ -52,6 +52,22 @@ describe('Routes Service', () => { }); }); + describe('#find', () => { + it('should return node found based on query', () => { + service.add(routes); + const result = service.find(route => route.invisible); + 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.find(route => route.requiredPolicy === 'X'); + expect(result).toBe(null); + }); + }); + describe('#remove', () => { it('should remove routes based on given routeNames', () => { service.add(routes); From 8037b9e369e51412365c1bb28aa702f3e6c52c0f Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Sun, 21 Jun 2020 04:44:35 +0300 Subject: [PATCH 06/16] feat: ignore Administration in breadcrumb --- .../src/lib/components/breadcrumb/breadcrumb.component.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb/breadcrumb.component.ts b/npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb/breadcrumb.component.ts index 4421a864c3..c556776b63 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb/breadcrumb.component.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb/breadcrumb.component.ts @@ -8,6 +8,7 @@ import { } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; import { filter, map, startWith } from 'rxjs/operators'; +import { eThemeSharedRouteNames } from '../../enums'; @Component({ selector: 'abp-breadcrumb', @@ -42,7 +43,7 @@ export class BreadcrumbComponent implements OnDestroy, OnInit { while (node.parent) { node = node.parent; const { parent, children, isLeaf, ...segment } = node; - this.segments.unshift(segment); + if (!isAdministration(segment)) this.segments.unshift(segment); } this.cdRef.detectChanges(); @@ -50,3 +51,7 @@ export class BreadcrumbComponent implements OnDestroy, OnInit { }); } } + +function isAdministration(route: ABP.Route) { + return route.name === eThemeSharedRouteNames.Administration; +} From a894ea38ce25ec87c3765db5f9318ece184ca427 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Sun, 21 Jun 2020 05:42:18 +0300 Subject: [PATCH 07/16] feat: add and use utility for finding closest routes --- .../components/dynamic-layout.component.ts | 7 +++--- .../core/src/lib/services/routes.service.ts | 2 +- .../core/src/lib/tests/route-utils.spec.ts | 24 ++++++++++++++++++- .../core/src/lib/utils/route-utils.ts | 17 +++++++++++++ .../src/lib/providers/route.provider.ts | 2 +- 5 files changed, 46 insertions(+), 6 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 6c7de6e6ef..25d49fb169 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 @@ -6,7 +6,7 @@ import { ABP } from '../models'; import { ReplaceableComponents } from '../models/replaceable-components'; import { RoutesService } from '../services/routes.service'; import { ReplaceableComponentsState } from '../states/replaceable-components.state'; -import { getRoutePath } from '../utils/route-utils'; +import { findRoute, getRoutePath } from '../utils/route-utils'; import { takeUntilDestroy } from '../utils/rxjs-utils'; import { TreeNode } from '../utils/tree-utils'; @@ -36,10 +36,11 @@ export class DynamicLayoutComponent implements OnDestroy { router.events.pipe(takeUntilDestroy(this)).subscribe(event => { if (event instanceof NavigationEnd) { let expectedLayout = (route.snapshot.data || {}).layout; - const path = getRoutePath(router); if (!expectedLayout) { - let node = { parent: routes.search({ path }) } as TreeNode; + let node = findRoute(routes, getRoutePath(router)); + node = { parent: node } as TreeNode; + while (node.parent) { node = node.parent; 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 bf3917f5cd..1548e09b79 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 @@ -77,7 +77,7 @@ export abstract class AbstractTreeService { return this.publish(flatItems, visibleItems); } - find(predicate: (item: TreeNode) => boolean, tree = this.tree) { + find(predicate: (item: TreeNode) => boolean, tree = this.tree): TreeNode | null { return tree.reduce( (acc, node) => (acc ? acc : predicate(node) ? node : this.find(predicate, node.children)), null, diff --git a/npm/ng-packs/packages/core/src/lib/tests/route-utils.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/route-utils.spec.ts index 41a761b84d..3945a6527d 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/route-utils.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/route-utils.spec.ts @@ -2,13 +2,35 @@ import { Component } from '@angular/core'; import { RouterModule } from '@angular/router'; import { createRoutingFactory, SpectatorRouting } from '@ngneat/spectator/jest'; import { RouterOutletComponent } from '../components'; -import { getRoutePath } from '../utils/route-utils'; +import { RoutesService } from '../services/routes.service'; +import { findRoute, getRoutePath } from '../utils/route-utils'; // tslint:disable-next-line @Component({ template: '' }) class DummyComponent {} describe('Route Utils', () => { + describe('#findRoute', () => { + const node = { path: '/foo' }; + + test.each` + path | expected | count + ${'/foo/bar/baz'} | ${node} | ${3} + ${'/foo/bar'} | ${node} | ${2} + ${'/foo'} | ${node} | ${1} + ${'/'} | ${null} | ${1} + `( + 'should find $expected in $count turns when path is $path', + async ({ path, expected, count }) => { + const find = jest.fn(cb => (cb(node) ? node : null)); + const routes = ({ find } as any) as RoutesService; + const route = findRoute(routes, path); + expect(route).toBe(expected); + expect(find).toHaveBeenCalledTimes(count); + }, + ); + }); + describe('#getRoutePath', () => { let spectator: SpectatorRouting; const createRouting = createRoutingFactory({ diff --git a/npm/ng-packs/packages/core/src/lib/utils/route-utils.ts b/npm/ng-packs/packages/core/src/lib/utils/route-utils.ts index 7ef062986a..56be0300f3 100644 --- a/npm/ng-packs/packages/core/src/lib/utils/route-utils.ts +++ b/npm/ng-packs/packages/core/src/lib/utils/route-utils.ts @@ -1,4 +1,21 @@ import { PRIMARY_OUTLET, Router, UrlSegmentGroup } from '@angular/router'; +import { ABP } from '../models/common'; +import { RoutesService } from '../services/routes.service'; +import { TreeNode } from './tree-utils'; + +export function findRoute(routes: RoutesService, path: string): TreeNode { + let node = routes.find(route => route.path === path); + + return node || path === '/' + ? node + : findRoute( + routes, + path + .split('/') + .slice(0, -1) + .join('/'), + ); +} export function getRoutePath(router: Router) { const emptyGroup = { segments: [] } as UrlSegmentGroup; diff --git a/npm/ng-packs/packages/theme-shared/src/lib/providers/route.provider.ts b/npm/ng-packs/packages/theme-shared/src/lib/providers/route.provider.ts index 0ffda1ac19..2c71c637fb 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/providers/route.provider.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/providers/route.provider.ts @@ -10,7 +10,7 @@ export function configureRoutes(routes: RoutesService) { return () => { routes.add([ { - path: '', + path: '/', name: eThemeSharedRouteNames.Administration, iconClass: 'fa fa-wrench', order: 1, From fa244b8c6f8373aca13b93adada0a4863d6ddb04 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Sun, 21 Jun 2020 22:03:49 +0300 Subject: [PATCH 08/16] refactor: use enum for setting management route name --- .../setting-management/config/src/providers/route.provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npm/ng-packs/packages/setting-management/config/src/providers/route.provider.ts b/npm/ng-packs/packages/setting-management/config/src/providers/route.provider.ts index 3ed75d33b0..dde2e0abfc 100644 --- a/npm/ng-packs/packages/setting-management/config/src/providers/route.provider.ts +++ b/npm/ng-packs/packages/setting-management/config/src/providers/route.provider.ts @@ -37,6 +37,6 @@ export function hideRoutes(routes: RoutesService, tabs: SettingTabsService) { debounceTime(0), map(nodes => !nodes.length), ) - .subscribe(invisible => routes.patch('AbpSettingManagement::Settings', { invisible })); + .subscribe(invisible => routes.patch(eSettingManagementRouteNames.Settings, { invisible })); }; } From 3131b51997bfe48bbea527963ed2cb1bb859eb71 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Sun, 21 Jun 2020 22:41:33 +0300 Subject: [PATCH 09/16] feat: allow more than two policy keys in getGrantedPolicy --- .../packages/core/src/lib/states/config.state.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/npm/ng-packs/packages/core/src/lib/states/config.state.ts b/npm/ng-packs/packages/core/src/lib/states/config.state.ts index d9b738b2aa..9d37dfe2f9 100644 --- a/npm/ng-packs/packages/core/src/lib/states/config.state.ts +++ b/npm/ng-packs/packages/core/src/lib/states/config.state.ts @@ -98,18 +98,19 @@ export class ConfigState { const orRegexp = /\|\|/g; const andRegexp = /&&/g; + // TODO: Allow combination of ANDs & ORs if (orRegexp.test(key)) { - const keys = key.split('||').filter(k => !!k); + const keys = key.split('||').filter(Boolean); - if (keys.length !== 2) return false; + if (keys.length < 2) return false; - return getPolicy(keys[0].trim()) || getPolicy(keys[1].trim()); + return keys.some(key => getPolicy(key.trim())); } else if (andRegexp.test(key)) { - const keys = key.split('&&').filter(k => !!k); + const keys = key.split('&&').filter(Boolean); - if (keys.length !== 2) return false; + if (keys.length < 2) return false; - return getPolicy(keys[0].trim()) && getPolicy(keys[1].trim()); + return keys.every(key => getPolicy(key.trim())); } return getPolicy(key); From 81c83d56f1caa48735fccbe934e86335a9f0888e Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Sun, 21 Jun 2020 22:50:45 +0300 Subject: [PATCH 10/16] test: stub routes service in breadcrumb specs --- .../src/lib/tests/breadcrumb.component.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/npm/ng-packs/packages/theme-shared/src/lib/tests/breadcrumb.component.spec.ts b/npm/ng-packs/packages/theme-shared/src/lib/tests/breadcrumb.component.spec.ts index 4ef000a2b1..bb8522c4a5 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/tests/breadcrumb.component.spec.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/tests/breadcrumb.component.spec.ts @@ -2,8 +2,15 @@ import { ABP, LocalizationPipe, RouterOutletComponent, RoutesService } from '@ab import { RouterModule } from '@angular/router'; import { createRoutingFactory, SpectatorRouting, SpyObject } from '@ngneat/spectator/jest'; import { Store } from '@ngxs/store'; +import { Subject } from 'rxjs'; import { BreadcrumbComponent } from '../components/breadcrumb/breadcrumb.component'; +const mockActions = new Subject(); +const mockStore = ({ + selectSnapshot() { + return true; + }, +} as unknown) as Store; const mockRoutes: ABP.Route[] = [ { name: 'Identity', path: '/identity' }, { name: 'Users', path: '/identity/users', parentName: 'Identity' }, @@ -19,6 +26,12 @@ describe('BreadcrumbComponent', () => { stubsEnabled: false, detectChanges: false, mocks: [Store], + providers: [ + { + provide: RoutesService, + useFactory: () => new RoutesService(mockActions, mockStore), + }, + ], declarations: [LocalizationPipe, BreadcrumbComponent], imports: [RouterModule], routes: [ From 51159b3693ddc72d4668c5ff5484dc2f48733923 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Mon, 22 Jun 2020 00:41:47 +0300 Subject: [PATCH 11/16] feat: add hasInvisibleChild method to routes service --- .../packages/core/src/lib/services/routes.service.ts | 5 +++++ .../packages/core/src/lib/tests/routes.service.spec.ts | 10 ++++++++++ 2 files changed, 15 insertions(+) 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 1548e09b79..dfd453fe04 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 @@ -144,6 +144,11 @@ export abstract class AbstractNavTreeService extends Abstract return this.store.selectSnapshot(ConfigState.getGrantedPolicy(requiredPolicy)); } + hasInvisibleChild(identifier: string): boolean { + const node = this.find(item => item[this.id] === identifier); + return node?.children?.some(child => child.invisible); + } + /* istanbul ignore next */ ngOnDestroy() {} } 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 ffb5c8b2f0..93214442ea 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 @@ -68,6 +68,16 @@ describe('Routes Service', () => { }); }); + describe('#hasInvisibleChild', () => { + it('should return if node has invisible child', () => { + service.add(routes); + + expect(service.hasInvisibleChild('foo')).toBe(true); + expect(service.hasInvisibleChild('bar')).toBe(false); + expect(service.hasInvisibleChild('baz')).toBe(false); + }); + }); + describe('#remove', () => { it('should remove routes based on given routeNames', () => { service.add(routes); From 01e01e4799d67ff417b4f827b085853b942a9d10 Mon Sep 17 00:00:00 2001 From: Arman Ozak Date: Mon, 22 Jun 2020 00:43:14 +0300 Subject: [PATCH 12/16] feat: evaluate nodes with hidden children as dropdowns --- .../src/lib/components/routes/routes.component.html | 2 +- .../theme-basic/src/lib/components/routes/routes.component.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.html b/npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.html index 4d22ddc8d2..d987f22860 100644 --- a/npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.html +++ b/npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.html @@ -1,7 +1,7 @@