Lazy Tab Navigation Concept

How to Implement a Lazy Tab Navigation System in Angular

In this blog post you’ll learn why you’d want and how to implement a lazy tab navigation system in Angular.

Why go for a lazy tab navigation system?

We at meshcloud provide a solution to manage cloud access, users and projects across cloud platforms. One of our main goals is to make the interaction with the cloud as easy as possible. Besides that we want to decrease the effort of management workflows to a minimum because cloud management is a complex topic with a lot of items to manage.

But how do we want to achieve this?

It is mostly a user facing topic. Therefore we’ve improved user experience in our Angular SPA (Single Page Application) and decreased the necessary interactions for specific management workflows. It is important to navigate between the sections without lagging or freezing.

It is really easy to say that the improvement of the user experience is the key. But what makes the difference to support user friendly behavior?

We’ve invested a lot of time to find a proper solution. We came up with a lazy tab navigation system is what we need which covers all mentioned challenges.
Lazy Tab Navigation Example

What is a lazy tab navigation system?

As the name suggests, the idea is a tab navigation system in which the content is loaded lazy. This will enable us to implement scalable views with a lot of clearly separated sections. But first to the general tab navigation system.

1. Tab navigation system

The tab navigation system is a central component which coordinates the tab creation and holds the tab state. We’ve decided to handle the tab creation based on the route config. This makes total sense because the routes are uniquely identifiable. This means we can automatically prevent the creation of multiple tabs which correspond to the same section. Besides that we have the possibility to create deep links to specific tab sections. This enables us to reference specific tab sections within other views. In general we’ll determine the tab state based on the activated route.

2. Lazy Loading

The other aspect is the lazy loading. Lazy loading is a central Angular feature. It is a design pattern that loads NgModules as needed. For comparison, there is the option to load NgModules eagerly. With eager loading all NgModules are loaded when accessing the web application. This increases the bundle size and for large applications it is not a best practice due to the load time. Eager loading is for a tab navigation system not an option because we’ve a lot of different sections. And we want to allow multiple sub-sections. This would mean that we load every section content up front. From the performance and UX perspective this is not a good approach.

If we now combine the tab navigation system approach with the lazy loading feature we’ll get a really flexible and scalable pattern. The tab navigation system takes the role of describing the context. It is kind of a container which defines the available routes and manages the tabs. In conjunction with the lazy loading feature, we are able to load the content of the respective tab in a dedicated manner.
Lazy Tab Navigation Concept

What is necessary to implement the lazy tab navigation system?

We’ve got an overview about the lazy tab navigation system from the conceptual perspective. Now we’ll take a look at the technical realization and how we’ve implemented this approach.

We need a shared component which covers all criteria of the tab navigation system. This includes the tab creation based on the router config and tab selection based on the activated route. We call the component RouterOutletNavComponent.

Besides that we’ve defined an interface which describes each tab:

  • displayName
  • routerLink
  • selected

Our interface declares additional properties. For example to show/hide a tab depending on a specific condition and to attach a badge with countable information. But this is not relevant for the general implementation. So we’ll leave it out for now.

We call the tab interface RouterOutletNavTab.

Sure, we didn’t implement the tab creation and tab selection logic within the component. We’ve implemented a service that exposes the functionality. This is a best practice to increase modularity and reusability. The component should only handle the user experience. With a clear separation we’ll also increase the testability of the functionalities. This approach should be followed every time ;)

We call the service RouterOutletNavService.

Now let's combine the RouterOutletNavComponent, RouterOutletNavTab and RouterOutletNavService. ****

@Component({
  selector: 'mst-router-outlet-nav',
  templateUrl: './router-outlet-nav.component.html',
  styleUrls: ['./router-outlet-nav.component.scss']
})
export class RouterOutletNavComponent implements OnInit, OnDestroy {

    @Input()
  public readonly styleClass: RouterOutletNavStyleClass = 'nav-child';

  public tabs: RouterOutletNavTab[];

  private sub: Subscription;

  constructor(
    private readonly router: Router,
    private readonly activatedRoute: ActivatedRoute,
    private readonly routerOutletNavService: RouterOutletNavService
  ) {
    this.tabs = this.routerOutletNavService.initializeTabs(this.activatedRoute);
  }

  ngOnInit() {
    /**
     * Select initial tab and navigate to child route.
     */
    this.setupInitiallySelectedTab()
      .subscribe((routerLink: string) => this.routerOutletNavService.selectTab(routerLink, this.tabs));

    /**
     * We listen to the router events to select the specific tab.
     */
    this.sub = this.router.events
      .pipe(
        filter(x => x instanceof NavigationEnd),
        switchMap((x: NavigationEnd) => {
          /**
           * If the firstChild is available then we don't determine the first child tab.
           */
          if (this.activatedRoute.firstChild) {
            return of(x.urlAfterRedirects);
          }

          /**
           * If child route doesn't exists then we'll determine the child route and select the tab.
           */
          return this.navigateToFirstChildRoute();
        })
      )
      .subscribe({
        next: (routerLink: string) => this.routerOutletNavService.selectTab(routerLink, this.tabs)
      });
  }

  ngOnDestroy(): void {
    this.sub.unsubscribe();
  }

  private setupInitiallySelectedTab(): Observable<string> {
    /**
     * If childs are applied for example in case of redirection then we'll use the existing destination url.
     */
    const currentUrl = this.router.url;
    if (this.activatedRoute.firstChild) {
      return of(currentUrl);
    }
    return this.navigateToFirstChildRoute();
  }

  private navigateToFirstChildRoute(): Observable<string> {
    return this.routerOutletNavService.findFirstChildRoute(this.tabs)
      .pipe(
        take(1),
        /**
         * This side effect is necessary to apply the determined tab route.
         */
        tap((routerLink: string) => {
          const extras = {
            relativeTo: this.activatedRoute,
            replaceUrl: true
          };

          this.router.navigate(['./', routerLink], extras);
        })
      );
  }
}

We want to support not only a tab navigation system. We want to support a lazy tab navigation system. So it is necessary to embed a RouterOutlet into the tab container. For the styling we use Bootstrap as external dependency.

The corresponding HTML file would look like this:

<div [ngClass]="styleClass">
  <ul class="container nav nav-tabs">
    <ng-container *ngFor="let t of tabs">
      <li class="nav-item">
        <a class="nav-link" [class.active]="t.selected" [routerLink]="[t.routerLink]" [id]="t.routerLink">
          {{t.displayName}}
        </a>
      </li>
    </ng-container>
  </ul>

  <div class="tab-content p-4">
    <router-outlet></router-outlet>
  </div>
</div>

It is a valid use case to nest multiple lazy tab navigation systems. But for simplification we allow only one additional layer. Therefore we’ve defined two different css classes ‘nav-root’ and ‘nav-child’ to tell the levels apart.

We call the style class type RouterOutletNavStyleClass.

export type RouterOutletNavStyleClass = 'nav-root' | 'nav-child';

Very well done. We've implemented our shared lazy tab navigation system.

Now we have to feed the tab navigation system with the corresponding data. The information about the router link and if a tab is selected will be determined based on the route config and activated route. It is really important to declare the available routes with the lazy loading approach from Angular.

But how do we get the display name of each tab? Sure, we could use the route path name. But the name is our uniquely identifiable name. It could also be a human unreadable string. From the user perspective, it is not a good approach to use an identifier string as tab display name. Besides that we need a way to determine some data up front. Keep in mind, besides the display name we’ve also a condition and badge information in place. So we need the possibility to attach specific session data to each tab. Therefore we need to declare a resolver which returns a RouterOutletNavSessionData observable.

We call the abstract resolver class RouterOutletNavSessionResolver.

export interface RouterOutletNavTabData {
  displayName: string;
}

export interface RouterOutletNavSessionData {
  [key: string]: RouterOutletNavTabData;
}

export abstract class RouterOutletNavSessionResolver implements Resolve<RouterOutletNavSessionData> {

  abstract resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RouterOutletNavSessionData>;

}

Now we’ve everything in place to use the lazy tab navigation system within a specific use case.

Lazy tab navigation system in action

At this point it makes sense to apply the lazy tab navigation system to one of our use cases. We’ve mentioned at the beginning that we provide a solution to manage cloud tenants. This includes for example the customer management. Within a customer we can manage projects, users, financials and much more. So it makes total sense to use the lazy tab navigation system with one additional level to make the management as easy as possible.

We’ll only consider the customer root level. The nested levels are out of scope for now. But in general they follow the same approach.

Our top level component would be the CustomerManagementComponent. This component declares the RouterOutletNavComponent within the HTML file and applies the ‘nav-root’ style class.

...
<mst-router-outlet-nav styleClass="nav-root"></mst-router-outlet-nav>
...

Then we’ll add all available routes to the CustomerManagementRoutingModule. Besides that, it's important to add a CustomerManagementNavSessionResolver to determine the session data up front. This provides our basis for the tab creation.

@Injectable({
  providedIn: 'root'
})
export class CustomerManagementNavSessionResolver extends RouterOutletNavSessionResolver {

  resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RouterOutletNavSessionData> {

    const sessionData = {} as RouterOutletNavSessionData;

    sessionData['projects'] = this.getProjectsNavTabData();
    sessionData['access-control'] = this.getAccessControlNavTabData();
    sessionData['financials'] = this.getFinancialsNavTabData();
    ...

    return of(sessionData);
  }

  private getProjectsNavTabData(): RouterOutletNavTabData {
    return {
      displayName: 'Projects'
    };
  }

  private getAccessControlNavTabData(): RouterOutletNavTabData {
    return {
      displayName: 'Access Control'
    };
  }

  private getFinancialsNavTabData(): RouterOutletNavTabData {
    return {
      displayName: 'Financials'
    };
  }

  ...
}
const routes: Routes = [
  {
    path: '',
    component: CustomerManagementComponent,
    resolve: {
      session: CustomerManagementNavSessionResolver
    },
    children: [
      {
        path: 'projects',
        loadChildren: () => import('./projects').then(m => ...)
      },
      {
        path: 'access-control',
        loadChildren: () => import('./access-control').then(m => ...)
      },
      {
        path: 'financials',
        loadChildren: () => import('./customer-financials').then(m => ...)
      },
      ...
    ]
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class CustomerManagementRoutingModule { }

That's it. No additional steps are necessary. We can easily add tabs and nested lazy tab navigation systems to the CustomerManagementComponent. For example we could think about a settings section or projects overview section. There are no limits to the imagination.

As inspiration our customer management view looks like this:
Lazy Tab Navigation Angular


Explicitly exposing APIs in Spring Data Rest

In our production system we use Spring Data Rest. We found out that it was too easy to leave a Repository method "exported" by default. We consider this as a security risk because it can be difficult to keep track of all repositories.
Therefore, we developed a new strategy to set the exported value of SDR methods to false to ensure security by default. The currently available detection strategies in SDR only allow to restrict REST repositories on class level. So when a Repository is exported, all of its methods are exported, too. Only by using RestResource(exported = false), you can prevent SDR from exporting a given method.

We created a new strategy to set the exported value of SDR methods to false. The currently available detection strategies in SDR only allow to restrict REST repositories on class level. So when a Repository is exported, all of its methods are exported, too. Only by using RestResource(exported = false), you can prevent SDR from exporting a given method.

We identified in our project, that there is a certain security risk in that case. Developers are not always aware of all the methods that are automatically exported via REST by the application. By simply adding new Repositories and just wanting a findAll()-method to be publicly available, even save and delete methods are exported by default. As most applications want to apply security especially on the write methods, an additional "pessimistic" strategy can be useful in Spring. That way you can still profit from all the benefits SDR provides, but you can be sure, that only methods you explicitly added and annotated with @RestResource are exported.

The following example shows how the exporting with the new strategy should work:

@RepositoryRestResource
interface PersonRepository extends Repository<Person, Long> {
@RestResource
Iterable findAll();

Iterable findByFirstname(@Param("firstname") String firstname);

}

In that case, only the findAll() method is exported via REST. The findByFirstName and all CRUD methods like save or delete are not exported via REST by default. They have to be added explictily and annotated with @RestResource if they shall be exported via REST.

We sent our solution as pull request to Spring Data REST. We got feedback and they decide that they would integrate our new feature (https://jira.spring.io/browse/DATAREST-1176 ).