Search code examples
angularangular-ui-router

How can We create dynamic route navigation Link in angular with multi level children and which will recreate whole navigation on reload


I wanted to create a navigation list based on some configuration. like an Array of

export interface LeftNavModel {
name: string,
route: string,
iconName?: string,
data?: any }

so navigation data will look something like

[
  {
    name: "First Route",
    route:"first",
    sectionName: "First Section",
    iconName: "home",
    children: [
      {
        name: "First Child Route",
        route:`first/firstChild`,
        iconName: "folder"
        children: [
          {
            name: "First Sub Child Route",
            route:"first/firstChild/firstSubChild",
            iconName: "image"
          }
        ]
      }
    ]
  }
]

This data will create nav link like enter image description here

And in a component create routerLink using the routes from the config JSON. I was able to create the same and is working fine. The issue is I wanted to share the navigated URL so other users can open the same page with navigation created dynamically.

Page Refresh can be handled by keeping config as state in router. Or using any storage mechanism like localStorage. But how can we share the link along with config data. Passing config as queryParam will create huge url. Any other option for the same?


Solution

  • I have implemented a solution for this issue, as there are no other answers available. I will now share what I have implemented to fulfill the requirement.

    I have created a config with all the required navigation for my application. Navigation component is loaded with required section of the config when a particular view is loaded

    Config Sample

    export const navConfig = [{
    sectionName:"Search",
    i18nKey: "search",
    sectionTopDivider: true,
    menuList:[
      {
        menuName: "Company1",
        i18nKey: "company1",
        route: "app/company1",
        prependIconName: "search",
        customClass:"text-success",
        appendIconName: "image",
      },
      {
        menuName: "Company2",
        i18nKey: "company2",
        route: "app/company2",
        prependIconName: "search",
        appendIconName: "image"
      }
    ]},{
    sectionName:"OverView",
    i18nKey:"OverView",
    sectionTopDivider: true,
    menuList:[
      {
        menuName: "Company 1 Overview",
        i18nKey: "company1 overview",
        route: "app/company1-overview",
        prependIconName: "home",
        appendIconName: "image",
        menuList: [
          {
            menuName: "Company1 Child Route",
            i18nKey: "",
            route: "company1/[departmentId]",
            prependIconName: "",
            appendSVGIconName: "",
          }
        ]
      },
      {
        menuName: "Company 2 Overview",
        i18nKey: "company2 overview",
        route: "app/company2-overview",
        prependIconName: "home",
        appendIconName: "image",
        menuList: [
          {
            menuName: "Company2 Child Route",
            i18nKey: "",
            route: "company2/[departmentId]",
            prependIconName: "",
            appendSVGIconName: "",
          }
        ]
      }
    ]}]
    

    All the application related files are attached to the snippet. You can insert this navigation component to your applicationlike below

    <app-left-navbar></app-left-navbar>
    

    To load the navigation config, inject navigation service into your constructor and load required navigation config Eg:

    private navService: LeftNavbarService, 
    this.navService.setNavConfigList(navConfig);
    

    Where navConfig is your required nav config as per the model left-nav.model.ts

    If you wanted to load a particular section from the config, you can do it like below

    this.route.url.subscribe(url=>{
      let headerModel: HeaderModel = {
        mainLabel: "New Header",
        mainIconName: "menu",
        subLabel: "Back to dashboard",
        subIconName: "arrow_back",
        subRoute: "./"
      }
      this.navService.setNavHeader(headerModel);  // new header model if required
    
      let defaultNavConfig = this.navService.getNavConfigList(); // featch loaded config from nav service
      let section = this.navService.getConfig(defaultNavConfig, 'sectionName', 'OverView'); //fetch required section from config using section name
      section.hidden = false; //edit section visibility if required
    
      this.navService.setNavList(defaultNavConfig); // set nav with updated config. You can edit already existing config or can add new config if required
    }) 
    

    Please go through the service to understand the working.

    //left-navbar.service.ts
    
    import { Injectable } from '@angular/core';
    import { BehaviorSubject, Observable, take } from 'rxjs';
    import { HeaderModel, LeftMenuConfig, MenuItemConfig } from '../models/left-nav.model';
    import * as _ from 'lodash';
    @Injectable({
      providedIn: 'root'
    })
    export class LeftNavbarService {
    
      navConfigList!: LeftMenuConfig[];//new BehaviorSubject<LeftMenuConfig[]>([]);
      navList = new BehaviorSubject<LeftMenuConfig[]>([]);
      headerModel = new BehaviorSubject<HeaderModel>(<HeaderModel>{});
      // footerModel = new BehaviorSubject<HeaderModel>(<HeaderModel>{});
      activeNavModel!: MenuItemConfig;
      constructor() { }
    
      getNavList(): Observable<LeftMenuConfig[]> {
        return this.navList.asObservable();
      }
    
      getNavConfigList(): LeftMenuConfig[] {
        return _.cloneDeep(this.navConfigList);
      }
    
      setNavList(navList: LeftMenuConfig[]) {
        this.navList.next(navList)
      }
    
      setNavConfigList(navList: LeftMenuConfig[]) {
        this.navConfigList = navList;
      }
    
      getNavHeader(): Observable<HeaderModel> {
        return this.headerModel.asObservable();
      }
    
      setNavHeader(headerModel: HeaderModel) {
        this.headerModel.next(headerModel);
      }
    
      // getNavFooter(): Observable<HeaderModel> {
      //   return this.footerModel.asObservable();
      // }
    
      // setNavFooter(footerModel: HeaderModel) {
      //   this.footerModel.next(footerModel);
      // }
    
      getNavListPromise() {
        return this.navList.pipe(take(1)).toPromise();
      }
    
      setActiveNavModel(navModel: MenuItemConfig) {
        this.activeNavModel = navModel;
      }
    
      getActiveNavModel() {
        return this.activeNavModel;
      }
    
      getConfig(listToMatch: Array<any>, key: string, value: string): any {
        let matched;
        for (let i = 0; i < listToMatch.length; i++) {
          let item = listToMatch[i];
          if (item[key] === value) {
            matched = item;
            break;
          }
          if (item.menuList && item.menuList.length) {
            matched = this.getConfig(item.menuList, 'menuName', value);
            if (matched) break;
          }
        }
        return matched;
      }
    }
    
    //left-nav.model.ts
    
    export interface LeftMenuConfig {
        sectionName: string,
        i18nKey: string,
        sectionTopDivider?: boolean,
        sectionBottomDivider?: boolean,
        hidden?:boolean,
        menuList: MenuItemConfig[]
    }
    
    export interface MenuItemConfig {
        menuName: string,
        i18nKey: string,
        route: string,
        prependIconName?: ((item: MenuItemConfig) => string) | string,
        prependSVGIconName?: ((item: MenuItemConfig) => string) | string,
        appendIconName?: ((item: MenuItemConfig) => string) | string,
        appendSVGIconName?: ((item: MenuItemConfig) => string) | string,
        data?: any,
        rawHtml?: ((item: MenuItemConfig) => string) | string,
        customClass?: ((item: MenuItemConfig) => string) | string, 
        formatterComponent?: any,
        hidden?: boolean,
        disabled?:boolean,
        privilege?:string,
        selected?: boolean,
        menuList?: MenuItemConfig[]
    }
    
    export interface HeaderModel {
        mainLabel: string,
        mainRoute?: string,
        mainIconName?: string,
        mainSvgIconName?: string,
        subLabel?: string,
        subRoute?: string,
        subIconName?: string,
        subSvgIconName?: string,
    }
    
    //left-navbar.component.ts
    
    import { Component, ElementRef, Input, OnInit } from '@angular/core';
    import { ActivatedRoute, NavigationStart, Router } from '@angular/router';
    import { filter, Observable } from 'rxjs';
    import { HeaderModel, LeftMenuConfig, MenuItemConfig } from '../models/left-nav.model';
    import { LeftNavbarService } from '../services/left-navbar.service';
    import { animateText, onSideNavChange } from './left-nav-animations';
    
    @Component({
      selector: 'app-left-navbar',
      templateUrl: './left-navbar.component.html',
      styleUrls: ['./left-navbar.component.scss'],
      animations: [onSideNavChange, animateText]
    })
    export class LeftNavbarComponent implements OnInit {
      constructor(
        private navbarService: LeftNavbarService,
        private route: ActivatedRoute,
        private router: Router
      ) {
        this.router.events
          .pipe(
            filter(event => event instanceof NavigationStart)
          )
          .subscribe(
            (event) => {
              let url = this.route.url;
              console.log(url);
            }
          );
    
      }
    
      navList!: Observable<LeftMenuConfig[]>;
      headerModel!: Observable<HeaderModel>;
      footerModel!: any;
      expanded = true;
      navState = true;
      @Input() leftPadding: number = 0; // Initial left padding of list item
      @Input() listNodePadding: number = 20 // Left padding of children list items for each level
    
      ngOnInit() {
        this.navList = this.navbarService.getNavList();
        this.headerModel = this.navbarService.getNavHeader();
        this.footerModel = {
          mainLabel: 'Help & Support',
          mainRoute: '',
          mainIconName: 'help_outline',
          subLabel: 'Help Sub Label',
          subRoute: '',
          subIconName: 'copyright',
        };
      }
    
      getCustomIcon(item: any, key: string) {
        if (item[key] && typeof (item[key]) === 'function') {
          return item[key](item);
        } else return item[key];
      }
    
      toggleLeftNav() {
        this.navState = !this.navState;
        setTimeout(() => {
          this.expanded = this.navState;
        }, 200)
      }
    
      onActivateRoute(isActive: boolean, item: MenuItemConfig) {
        if (isActive) {
          this.navbarService.setActiveNavModel(item);
        };
      }
    }
    //left-navbar.component.scss
    
    .left-nav-container {
        ::ng-deep {
            .mat-list-item {
                height: 40px !important;
                align-items: center;
    
                // border-left: 4px solid transparent;
                // &.active {
                //     // border-left: 4px solid #303030;
                // }
                .mat-list-item-content {
                    overflow: hidden;
                    padding: 0 0 0 4px !important;
                    width: 100%;
                }
    
                &:disabled {
                    cursor: not-allowed;
                    pointer-events: none;
                    opacity: 0.6;
                }
            }
    
            .mat-subheader {
                padding: 0 !important;
            }
    
            .mat-nav-list.footer-section {
                margin-top: auto !important;
            }
    
            .mat-list-text {
                padding-left: 0 !important;
            }
        }
    
        &.expanded {
            // min-width: 220px;
        }
    
        &.collapsed {
            ::ng-deep {
                .mat-list-item {
                    // width: 40px !important;
                    justify-content: center;
                }
            }
        }
    
        .menu-group-container {
            padding: 0 4px;
    
            &.active-container {
                border-radius: 4px;
                background-color: #e8ecef80;
                padding: 4px;
            }
        }
    
        .active-container {
            border-radius: 4px;
            background-color: #e8ecef80;
            padding: 4px;
        }
    
    }
    
    .expandCollapseBtn {
        width: 24px;
        height: 24px;
        background-color: #8b9daf;
        border-radius: 50%;
        display: inline-block;
        right: -12px;
        top: 10px;
        z-index: 4;
        cursor: pointer;
    
        span.material-icons {
            color: white;
        }
    }
    
    .child-indicator-elm {
        position: absolute;
        width: 8px;
        height: 30px;
        z-index: 1;
    
        &::before {
            content: "";
            float: left;
            width: 8px;
            height: inherit;
            background: transparent;
            border: 2px solid #A3B1BF;
            border-bottom-left-radius: 6px;
            border-top: 0;
            border-right: 0;
            position: absolute;
            top: 0px;
            left: 0px;
        }
    
        &::after {
            content: "";
            float: right;
            width: 4px;
            height: 4px;
            border-radius: 50%;
            position: absolute;
            right: -3px;
            bottom: -1px;
            background-color: #A3B1BF;
        }
    
    }
    
    .display-none {
        display: none !important;
    }
    //left-navbar.component.html
    <div [@onSideNavChange]="navState ? 'open' : 'close'" class="left-nav-container p-2 h-100 position-relative"
        [ngClass]="{'expanded':expanded, 'collapsed':!expanded}">
        <span class="expandCollapseBtn position-absolute" (click)="toggleLeftNav()">
            <span *ngIf="expanded" class="material-icons">arrow_left</span>
            <span *ngIf="!expanded" class="material-icons">arrow_right</span>
        </span>
        <div class="w-100 h-100 d-flex flex-column align-items-stretched justify-content-start">
            <ng-container *ngIf="headerModel | async">
                <ng-container *ngTemplateOutlet="headerTmpl; context:{item: headerModel | async, class: 'header-section'}">
                </ng-container>
            </ng-container>
            <mat-nav-list dense>
                <ng-container *ngFor="let item of navList | async">
                    <ng-container *ngIf="!item?.hidden">
                        <mat-divider *ngIf="item?.sectionTopDivider"></mat-divider>
                        <span *ngIf="item?.sectionName && expanded" mat-subheader>{{item?.sectionName}}</span>
                        <ng-container *ngFor="let menuItem of item?.menuList">
                            <div [appUserPrivilege]="menuItem?.privilege" class="menu-group-container"
                                [ngClass]="{'p-0':!expanded, 'active-container': menuItem?.selected}">
                                <ng-container *ngIf="menuItem?.formatterComponent">
                                    <app-formatter-navlink [navModel]="menuItem"></app-formatter-navlink>
                                </ng-container>
                                <ng-container *ngIf="menuItem?.rawHtml">
                                    <a mat-list-item class="d-flex rounded" [routerLink]="menuItem?.route"
                                        routerLinkActive="active"
                                        [innerHTML]="menuItem | navCustomHandler : 'rawHtml' | safeHtml">
                                    </a>
                                </ng-container>
                                <ng-container
                                    *ngIf="!menuItem?.formatterComponent && !menuItem?.rawHtml && !menuItem?.hidden">
                                    <div appCustomClassHandler [menuItem]="menuItem">
                                        <a [disabled]="menuItem?.disabled" mat-list-item
                                            (isActiveChange)="onActivateRoute($event,menuItem)"
                                            [routerLink]="menuItem?.route" class="d-flex rounded"
                                            [style.padding-left.px]="expanded ? leftPadding: 0"
                                            [routerLinkActiveOptions]="{exact: true}" [state]="{config: menuItem}"
                                            routerLinkActive="active">
                                            <mat-icon *ngIf="menuItem?.prependIconName" matListIcon
                                                [innerText]="menuItem | navCustomHandler: 'prependIconName'"></mat-icon>
                                            <mat-icon *ngIf="menuItem?.prependSVGIconName" mat-list-icon
                                                svgIcon="{{ getCustomIcon(menuItem, 'prependSVGIconName') }}"></mat-icon>
                                            <div class="ps-1" [@animateText]="expanded ? 'show' : 'hide'" matLine>
                                                {{menuItem?.i18nKey}}
                                            </div>
                                            <mat-icon *ngIf="menuItem?.appendIconName"
                                                [@animateText]="expanded ? 'show' : 'hide'"
                                                [innerText]="menuItem | navCustomHandler: 'appendIconName'"></mat-icon>
                                            <mat-icon class="d-flex" *ngIf="menuItem?.appendSVGIconName"
                                                [@animateText]="expanded ? 'show' : 'hide'"
                                                svgIcon="{{ getCustomIcon(menuItem, 'appendSVGIconName') }}"></mat-icon>
                                        </a>
                                    </div>
                                </ng-container>
                                <ng-container *ngIf="!menuItem.hidden && menuItem?.menuList">
                                    <ng-container
                                        *ngTemplateOutlet="navTmpl; context:{menuItem: menuItem, leftPadding: leftPadding + listNodePadding }">
                                    </ng-container>
                                </ng-container>
                            </div>
                        </ng-container>
                        <mat-divider *ngIf="item?.sectionBottomDivider"></mat-divider>
                    </ng-container>
                </ng-container>
            </mat-nav-list>
            <ng-container *ngIf="footerModel">
                <ng-container
                    *ngTemplateOutlet="footerTmpl; context:{item: footerModel, class: 'footer-section', footer: true}">
                </ng-container>
            </ng-container>
        </div>
    </div>
    
    <ng-template #navTmpl let-parentConfig="menuItem" let-menuItem="menuItem" let-leftPadding="leftPadding">
        <mat-nav-list dense>
            <ng-container *ngFor="let menuItem of menuItem?.menuList; let i=index;">
                <ng-container *ngIf="menuItem && !menuItem?.hidden">
                    <div [ngClass]="{'active-container':menuItem?.selected}" [appUserPrivilege]="menuItem?.privilege">
                        <ng-container *ngIf="menuItem?.formatterComponent">
                            <app-formatter-navlink [navModel]="menuItem"></app-formatter-navlink>
                        </ng-container>
                        <ng-container *ngIf="menuItem?.rawHtml">
                            <a mat-list-item class="d-flex rounded" [routerLink]="menuItem?.route" routerLinkActive="active"
                                [innerHTML]="menuItem.rawHtml(menuItem) | safeHtml">
                            </a>
                        </ng-container>
                        <ng-container *ngIf="!menuItem?.formatterComponent && !menuItem?.rawHtml">
                            <div class="position-relative" appCustomClassHandler [menuItem]="menuItem">
                                <span *ngIf="expanded" appFluidHeight [refElement]="linkRef" [parentConfig]="parentConfig"
                                    [index]="i" [style.bottom.px]="linkRef['_element']?.nativeElement?.offsetHeight/2"
                                    [style.left.px]="leftPadding" class="child-indicator-elm"></span>
                                <a #linkRef [disabled]="menuItem?.disabled" mat-list-item
                                    [style.padding-left.px]="expanded ? leftPadding: 0" class="d-flex rounded"
                                    (isActiveChange)="onActivateRoute($event,menuItem)" [routerLink]="menuItem?.route"
                                    [state]="{config: menuItem}" routerLinkActive="active"
                                    [routerLinkActiveOptions]="{exact: true}">
                                    <mat-icon *ngIf="menuItem?.prependIconName" matListIcon
                                        [innerText]="menuItem | navCustomHandler: 'prependIconName'"></mat-icon>
                                    <mat-icon *ngIf="menuItem?.prependSVGIconName" mat-list-icon
                                        svgIcon="{{ getCustomIcon(menuItem, 'prependSVGIconName') }}"></mat-icon>
                                    <div class="ps-1" [@animateText]="expanded ? 'show' : 'hide'" matLine>
                                        {{menuItem?.i18nKey}}
                                    </div>
                                    <mat-icon *ngIf="menuItem?.appendIconName" [@animateText]="expanded ? 'show' : 'hide'"
                                        [innerText]="menuItem | navCustomHandler: 'appendIconName'"></mat-icon>
                                    <mat-icon class="d-flex" *ngIf="menuItem?.appendSVGIconName"
                                        [@animateText]="expanded ? 'show' : 'hide'"
                                        svgIcon="{{ getCustomIcon(menuItem, 'appendSVGIconName') }}"></mat-icon>
                                </a>
                            </div>
                        </ng-container>
                        <ng-container *ngIf="menuItem?.menuList">
                            <ng-container
                                *ngTemplateOutlet="navTmpl; context:{menuItem: menuItem, leftPadding: leftPadding + listNodePadding}">
                            </ng-container>
                        </ng-container>
                    </div>
                </ng-container>
            </ng-container>
        </mat-nav-list>
    </ng-template>
    
    <ng-template #headerTmpl let-item="item" let-class="class" let-isFooter="footer">
        <mat-nav-list [ngClass]="class" dense>
            <mat-divider *ngIf="isFooter"></mat-divider>
            <ng-container *ngIf="item?.mainLabel">
                <a mat-list-item [@animateText]="expanded ? 'show' : 'hide'" [ngClass]="{'d-flex mainLabel rounded': true}"
                    [routerLink]="item?.mainRoute" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">
                    <mat-icon *ngIf="item.mainIconName" matListIcon>{{item?.mainIconName}}</mat-icon>
                    <mat-icon *ngIf="item.mainSvgIcon" mat-list-icon svgIcon="{{ item.mainSvgIcon }}"></mat-icon>
                    <div [@animateText]="expanded ? 'show' : 'hide'" matLine class="ms-2 ml-2">{{item?.mainLabel}}</div>
                </a>
            </ng-container>
            <ng-container *ngIf="item?.subLabel">
                <a mat-list-item [ngClass]="{'d-flex subLabel rounded': true}" [routerLink]="item?.subRoute"
                    routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">
                    <mat-icon *ngIf="item.subIconName" matListIcon>{{item?.subIconName}}</mat-icon>
                    <mat-icon *ngIf="item.subSvgIcon" mat-list-icon svgIcon="{{ item.subSvgIcon }}"></mat-icon>
                    <div [@animateText]="expanded ? 'show' : 'hide'" matLine class="ms-2 ml-2">{{item?.subLabel}}</div>
                </a>
            </ng-container>
        </mat-nav-list>
    </ng-template>
    
    <ng-template #footerTmpl let-item="item" let-class="class">
        <mat-nav-list [ngClass]="class" dense>
            <mat-divider *ngIf="class"></mat-divider>
            <ng-container *ngIf="item?.mainLabel">
                <a mat-list-item [ngClass]="{'d-flex mainLabel rounded': true}" [routerLink]="item?.mainRoute"
                    routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">
                    <mat-icon *ngIf="item.mainIconName" matListIcon>{{item?.mainIconName}}</mat-icon>
                    <mat-icon *ngIf="item.mainSvgIcon" mat-list-icon svgIcon="{{ item.mainSvgIcon }}"></mat-icon>
                    <div [@animateText]="expanded ? 'show' : 'hide'" matLine class="ms-2 ml-2">{{item?.mainLabel}}</div>
                </a>
            </ng-container>
            <ng-container *ngIf="item?.subLabel">
                <a mat-list-item [ngClass]="{'d-flex subLabel rounded': true}" [routerLink]="item?.subRoute"
                    routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">
                    <mat-icon *ngIf="item.subIconName" matListIcon>{{item?.subIconName}}</mat-icon>
                    <mat-icon *ngIf="item.subSvgIcon" mat-list-icon svgIcon="{{ item.subSvgIcon }}"></mat-icon>
                    <div [@animateText]="expanded ? 'show' : 'hide'" matLine class="ms-2 ml-2">{{item?.subLabel}}</div>
                </a>
            </ng-container>
        </mat-nav-list>
    </ng-template>