diff --git a/projects/demo/src/app/app.component.spec.ts b/projects/demo/src/app/app.component.spec.ts index 12a8803..13704ee 100644 --- a/projects/demo/src/app/app.component.spec.ts +++ b/projects/demo/src/app/app.component.spec.ts @@ -1,13 +1,17 @@ import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; import { AppComponent } from './app.component'; -import { By } from '@angular/platform-browser'; +import { DynamicMenuService } from 'projects/dynamic-menu/src/public_api'; describe('Component: App', () => { beforeEach(() => { TestBed.configureTestingModule({ + imports: [RouterTestingModule], declarations: [AppComponent], + providers: [{ provide: DynamicMenuService, useValue: {} }], schemas: [CUSTOM_ELEMENTS_SCHEMA], }); }); diff --git a/projects/demo/src/app/app.component.ts b/projects/demo/src/app/app.component.ts index 0c5fa24..98552c9 100644 --- a/projects/demo/src/app/app.component.ts +++ b/projects/demo/src/app/app.component.ts @@ -1,4 +1,5 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { DynamicMenuService } from 'projects/dynamic-menu/src/public_api'; @Component({ selector: 'app-root', @@ -6,4 +7,29 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; styleUrls: ['./app.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AppComponent {} +export class AppComponent implements OnInit { + constructor(private dynamicMenuService: DynamicMenuService) {} + + ngOnInit(): void { + this.dynamicMenuService.addMenuAfter(['path3', ':id', 'path6'], { + path: 'custom-path', + data: { menu: { label: 'Custom Section' } }, + }); + this.dynamicMenuService.addMenuToStart(['path3', ':id', 'path6'], { + path: 'custom-path2', + data: { menu: { label: 'Custom Section - Start' } }, + }); + this.dynamicMenuService.addMenuToEnd(['path3', ':id', 'path6'], { + path: 'custom-path3', + data: { menu: { label: 'Custom Section - End' } }, + }); + this.dynamicMenuService.addMenuToStart([''], { + path: 'custom-path-start', + data: { menu: { label: 'Custom Section - Start' } }, + }); + this.dynamicMenuService.addMenuToEnd([''], { + path: 'custom-path-end', + data: { menu: { label: 'Custom Section - End' } }, + }); + } +} diff --git a/projects/dynamic-menu/src/lib/dynamic-menu.service.spec.ts b/projects/dynamic-menu/src/lib/dynamic-menu.service.spec.ts index 40d8a7a..bc6a761 100644 --- a/projects/dynamic-menu/src/lib/dynamic-menu.service.spec.ts +++ b/projects/dynamic-menu/src/lib/dynamic-menu.service.spec.ts @@ -210,7 +210,7 @@ describe('Service: DynamicMenu', () => { expect(menu[1].path).toBe('child'); expect(menu[1].data.menu.label).toBe('Child'); - expect(menu[1].data.menu.children.length).toBe(0); // Not yet loaded + expect(menu[1].data.menu.children).toBeUndefined(); // Not yet loaded callback.calls.reset(); diff --git a/projects/dynamic-menu/src/lib/dynamic-menu.service.ts b/projects/dynamic-menu/src/lib/dynamic-menu.service.ts index 2773c24..7aab048 100644 --- a/projects/dynamic-menu/src/lib/dynamic-menu.service.ts +++ b/projects/dynamic-menu/src/lib/dynamic-menu.service.ts @@ -1,37 +1,63 @@ -import { Injectable, Injector } from '@angular/core'; +import { Injectable, Injector, OnDestroy } from '@angular/core'; import { ActivatedRoute, NavigationEnd, + Params, Route, RouteConfigLoadEnd, Router, - Params, } from '@angular/router'; -import { combineLatest, EMPTY, zip } from 'rxjs'; +import { combineLatest, EMPTY, Subject, zip } from 'rxjs'; import { delay, filter, map, publishBehavior, refCount, + scan, startWith, - tap, + takeUntil, } from 'rxjs/operators'; import { DynamicMenuExtrasToken } from './dynamic-menu-extras'; import { DYNAMIC_MENU_ROUTES_TOKEN } from './dynamic-menu-routes'; import { SUB_MENU_MAP_TOKEN, SubMenuMap } from './sub-menu-map-provider'; import { - DynamicMenuConfigFn, + DynamicMenuConfigResolver, DynamicMenuRouteConfig, RoutesWithMenu, RouteWithMenu, } from './types'; +export interface CustomMenu { + location: string[]; + mode: 'insert' | 'start' | 'end'; + menu: RoutesWithMenu; + used?: boolean; +} + +export type AnyMenuRoute = RouteWithMenu | DynamicMenuRouteConfig; + +export type MenuVisitor = ( + node: AnyMenuRoute, + parentNode?: AnyMenuRoute, + acc?: AnyMenuRoute[], +) => { node: T; acc?: T[] }; + @Injectable({ providedIn: 'root', }) -export class DynamicMenuService { +export class DynamicMenuService implements OnDestroy { + private destroyed$ = new Subject(); + + private addCustomMenu$ = new Subject(); + + private customMenu$ = this.addCustomMenu$.pipe( + scan((acc, customMenu) => [...acc, ...customMenu]), + publishBehavior([]), + refCount(), + ); + private configChanged$ = this.dynamicMenuExtrasToken.listenForConfigChanges ? this.router.events.pipe(filter(e => e instanceof RouteConfigLoadEnd)) : EMPTY; @@ -55,13 +81,21 @@ export class DynamicMenuService { map(([routes, subMenuMap]) => this.buildFullUrlTree(routes, subMenuMap)), ); - private dynamicMenu$ = combineLatest( + private fullMenu$ = combineLatest( this.basicMenu$, - this.navigationEnd$.pipe(startWith(null)), + this.customMenu$, + this.subMenuMap$, ).pipe( - map(([basicMenu]) => - this.updateFullPaths(basicMenu, this.router.routerState.root), + map(([basicMenu, customMenu, subMenuMap]) => + this.resolveWithCustomMenu(basicMenu, customMenu, subMenuMap), ), + ); + + private dynamicMenu$ = combineLatest( + this.fullMenu$, + this.navigationEnd$.pipe(startWith(null)), + ).pipe( + map(([menu]) => this.updateFullPaths(menu, this.router.routerState.root)), publishBehavior([] as DynamicMenuRouteConfig[]), refCount(), ); @@ -70,7 +104,13 @@ export class DynamicMenuService { private injector: Injector, private router: Router, private dynamicMenuExtrasToken: DynamicMenuExtrasToken, - ) {} + ) { + this.dynamicMenu$.pipe(takeUntil(this.destroyed$)).subscribe(); + } + + ngOnDestroy(): void { + this.destroyed$.next(); + } getMenu() { return this.dynamicMenu$; @@ -80,7 +120,37 @@ export class DynamicMenuService { return this.router.isActive(this.router.createUrlTree(fullPath), exact); } - private getDynamicMenuRoutes() { + addMenuAfter(fullPath: string[], menu: RouteWithMenu | RoutesWithMenu) { + this.addCustomMenu$.next([ + { + location: fullPath, + mode: 'insert', + menu: Array.isArray(menu) ? menu : [menu], + }, + ]); + } + + addMenuToStart(fullPath: string[], menu: RouteWithMenu | RoutesWithMenu) { + this.addCustomMenu$.next([ + { + location: fullPath, + mode: 'start', + menu: Array.isArray(menu) ? menu : [menu], + }, + ]); + } + + addMenuToEnd(fullPath: string[], menu: RouteWithMenu | RoutesWithMenu) { + this.addCustomMenu$.next([ + { + location: fullPath, + mode: 'end', + menu: Array.isArray(menu) ? menu : [menu], + }, + ]); + } + + private getDynamicMenuRoutes(): RoutesWithMenu { return this.injector .get(DYNAMIC_MENU_ROUTES_TOKEN, []) .reduce((acc, routes) => [...acc, ...routes], this.router.config); @@ -123,6 +193,10 @@ export class DynamicMenuService { menu: DynamicMenuRouteConfig[], route: ActivatedRoute | null, ): DynamicMenuRouteConfig[] { + if (!menu) { + return menu; + } + return menu.map(m => { return { ...m, @@ -166,106 +240,230 @@ export class DynamicMenuService { return params; } + private resolveWithCustomMenu( + basicMenu: DynamicMenuRouteConfig[], + customMenu: CustomMenu[], + subMenuMap: SubMenuMap[], + ) { + return this.combineMenuWithCustom( + basicMenu, + customMenu, + (config, parentConfig) => + this.resolveMenuConfig(subMenuMap, config, parentConfig), + ); + } + private buildFullUrlTree( node: RoutesWithMenu, subMenuMap: SubMenuMap[], ): DynamicMenuRouteConfig[] { - return this.buildUrlTree(node, (config, parentConfig) => { - const path = parentConfig - ? parentConfig.fullPath || [parentConfig.path] - : []; - - if (config.data && config.data.menu) { - config.data.menu.subMenuComponent = this.resolveSubMenuComponent( - config, - subMenuMap, - ); - } - - // tslint:disable-next-line: no-non-null-assertion - const fullPath: string[] = [...path, config.path!].filter(p => p != null); - - // Setting to `pathUrl` for now. - // Will be updated later after navigation. - const fullUrl: string[] = fullPath; + return this.buildUrlTree(node, (config, parentConfig) => + this.resolveMenuConfig(subMenuMap, config, parentConfig), + ); + } - return { ...config, fullPath, fullUrl }; - }); + private resolveMenuConfig( + subMenuMap: SubMenuMap[], + config: RouteWithMenu, + parentConfig?: DynamicMenuRouteConfig, + ): DynamicMenuRouteConfig { + const path = parentConfig + ? parentConfig.fullPath || [parentConfig.path] + : []; + + const subMenuComponent = + config.data && config.data.menu + ? this.resolveSubMenuComponent(config, subMenuMap) + : undefined; + + // tslint:disable-next-line: no-non-null-assertion + const fullPath: string[] = [...path, config.path!].filter(p => p != null); + + // Setting to `pathUrl` for now. + // Will be updated later after navigation. + const fullUrl: string[] = fullPath; + + return { + ...config, + fullPath, + fullUrl, + data: { + ...config.data, + menu: { + label: '', + children: undefined as any, + ...(config.data && config.data.menu), + subMenuComponent, + }, + }, + }; } private buildUrlTree( - node: (Route | DynamicMenuRouteConfig)[], - fn: DynamicMenuConfigFn, + node: (RouteWithMenu | DynamicMenuRouteConfig)[], + fn: DynamicMenuConfigResolver, ): DynamicMenuRouteConfig[] { - const getConfig = ( - config: DynamicMenuRouteConfig, - parentConfig?: DynamicMenuRouteConfig, - ) => { - return fn(config, parentConfig); - }; - return this._buildUrlTree(node, getConfig); + return this.visitMenu(node, (config, configParent) => { + return { + node: fn( + config as DynamicMenuRouteConfig, + configParent as DynamicMenuRouteConfig, + ), + }; + }); } - private _buildUrlTree( - node: (Route | DynamicMenuRouteConfig)[], - fn: DynamicMenuConfigFn, - parentNode?: DynamicMenuRouteConfig, - ): DynamicMenuRouteConfig[] { - if (!node) { + private visitMenu( + nodes: AnyMenuRoute[], + cb: MenuVisitor, + parentNode?: AnyMenuRoute, + ): T[] { + if (!nodes) { return []; } - const usedPaths = {} as any; + return nodes.reduce( + (acc, node) => { + const shouldSkip = + !isConfigMenuItem(node) || this.shouldSkipConfig(node); + + const res = cb(node, parentNode, acc); - return node.reduce( - (paths, config) => { - if (config.path != null) { - if (config.path in usedPaths) { - return paths; + const children: unknown = + getMenuChildren(node) || + node.children || + getLoadedConfig(node) || + node.loadChildren; + + if (Array.isArray(children)) { + if (shouldSkip && parentNode && isConfigMenuItem(parentNode)) { + this.visitMenuChildren(parentNode, children, cb, res.node); + } else if (isConfigMenuItem(res.node)) { + this.visitMenuChildren(res.node, children, cb); } - usedPaths[config.path] = true; } - const newNode = fn(config as DynamicMenuRouteConfig, parentNode); - - if (!isConfigMenuItem(config) || this.shouldSkipConfig(config)) { - return [...paths, ...this.buildUrlTreeChild(config, fn, newNode)]; - } else if (typeof newNode === 'object') { - newNode.data.menu.children = this.buildUrlTreeChild( - config, - fn, - newNode, - ); - return [...paths, newNode]; + if (!shouldSkip) { + return res.acc ? res.acc : [...acc, res.node]; } else { - return [ - ...paths, - newNode, - ...this.buildUrlTreeChild(config, fn, newNode), - ]; + return acc; } }, - [] as DynamicMenuRouteConfig[], + [] as T[], ); } - private buildUrlTreeChild( - config: Route | DynamicMenuRouteConfig, - fn: DynamicMenuConfigFn, - parentNode: DynamicMenuRouteConfig, + private visitMenuChildren( + node: AnyMenuRoute, + children: AnyMenuRoute[], + cb: MenuVisitor, + parentNode: AnyMenuRoute = node, + ) { + const newChildren = this.visitMenu(children, cb, parentNode) as any; + + if (!getMenuChildren(node) && newChildren) { + setMenuChildren(node, newChildren); + } + + return node; + } + + private combineMenuWithCustom( + basicMenu: DynamicMenuRouteConfig[], + customMenu: CustomMenu[], + fn: DynamicMenuConfigResolver, ): DynamicMenuRouteConfig[] { - const children = - config.children || getLoadedConfig(config) || config.loadChildren; - - if (Array.isArray(children)) { - return this._buildUrlTree( - children as DynamicMenuRouteConfig[], - fn, - parentNode, - ); + if (!customMenu.length) { + return basicMenu; } - return []; + return this.visitMenu(basicMenu, (node, parentNode, nodes) => { + const newNodes = customMenu.reduce( + (acc, customItem) => { + if (customItem.used) { + return acc; + } + + const isStaticMode = + customItem.mode === 'start' || customItem.mode === 'end'; + + const isLocationMatch = isStaticMode + ? acc.some(n => this.isMenuMatch(customItem, n)) + : this.isMenuMatch(customItem, node); + + if (!isLocationMatch) { + return acc; + } + + const children = isConfigMenuItem(parentNode) + ? getMenuChildren(parentNode) || [] + : acc; + + const idxChildren = + isStaticMode && !isConfigMenuItem(parentNode) + ? basicMenu + : children; + + const idx = idxChildren.findIndex(c => c === node); + + if ( + idx === -1 || + (customItem.mode === 'end' && idx !== idxChildren.length - 1) + ) { + return acc; + } + + customItem.used = true; + + const menu = customItem.menu.map(m => { + const p = { + ...parentNode, + fullPath: (node as any).fullPath.slice(0, -1), + }; + return fn(m as any, p as any); + }); + + let newChildren = children; + + switch (customItem.mode) { + case 'insert': + newChildren = [ + ...children.slice(0, idx + 1), + ...menu, + ...children.slice(idx + 1), + ]; + break; + case 'start': + newChildren = [...menu, ...children]; + break; + case 'end': + newChildren = [...children, ...menu]; + break; + } + + if (isConfigMenuItem(parentNode)) { + setMenuChildren( + parentNode, + newChildren as DynamicMenuRouteConfig[], + ); + return acc; + } else { + return newChildren; + } + }, + [...(nodes || []), node] as AnyMenuRoute[], + ); + + return { node, acc: newNodes }; + }) as any; + } + + private isMenuMatch(menu: CustomMenu, node: AnyMenuRoute): boolean { + return ( + 'fullPath' in node && + node.fullPath.length === menu.location.length && + node.fullPath.every((p, i) => menu.location[i] === p) + ); } } @@ -273,6 +471,23 @@ function getLoadedConfig(config: any) { return config._loadedConfig && config._loadedConfig.routes; } -function isConfigMenuItem(config: Route): config is DynamicMenuRouteConfig { +function isConfigMenuItem(config?: Route): config is RouteWithMenu { return config && config.data && config.data.menu; } + +function getMenuChildren( + config?: RouteWithMenu, +): DynamicMenuRouteConfig[] | undefined { + return isConfigMenuItem(config) && config.data && config.data.menu + ? (config.data.menu as any).children + : undefined; +} + +function setMenuChildren( + config: RouteWithMenu, + children: DynamicMenuRouteConfig[], +) { + if (isConfigMenuItem(config) && config.data && config.data.menu) { + (config.data.menu as any).children = children; + } +} diff --git a/projects/dynamic-menu/src/lib/types.ts b/projects/dynamic-menu/src/lib/types.ts index 2113e9c..1d3bd72 100644 --- a/projects/dynamic-menu/src/lib/types.ts +++ b/projects/dynamic-menu/src/lib/types.ts @@ -54,7 +54,7 @@ export interface DynamicMenuRouteConfig extends RouteWithMenu { data: DynamicDataWithMenu; /** * Represents unprocessed full path from root to route. - * Only calculated once a router config is loaded. + * Only calculated once a router config is (re)loaded. */ fullPath: string[]; /** @@ -64,7 +64,7 @@ export interface DynamicMenuRouteConfig extends RouteWithMenu { fullUrl: string[]; } -export type DynamicMenuConfigFn = ( - config: DynamicMenuRouteConfig, +export type DynamicMenuConfigResolver = ( + config: RouteWithMenu, parentConfig?: DynamicMenuRouteConfig, ) => DynamicMenuRouteConfig;