18
votes

This is a conceptional question on how to implement the required functionality the "right" way with Angular 2.

My application has a navigation menu, a toolbar, and a content area. The latter contains the primary <router-outlet> and displays the different views like list and details.

What I want to achieve is that the toolbar is displaying different components, depending on the component/view that is rendered in the content area. For example, the list component needs a search control in the toolbar, while the details component needs a save button.

A) My first attempt was to add another (named) <router-outlet> to the toolbar and display the toolbar components based on static routes. What feels wrong about this:

  1. The static content-toolbar relation is coupled too loosely for my taste.
  2. The relation is visible (and changeable) in the URL.
  3. The toolbar outlet keeps this path even if the user navigates away.

B) My second attempt was to navigate imperatively to the toolbar components (also using the named toolbar outlet) in the main view component's ngOnInit, which couples it more tightly. What smells bad:

  1. A2
  2. A3, to prevent this I could "clear" the toolbar outlet on ngOnDestroy, but I haven't found out how.

C) Giving the router a last chance, since I found out that this kind-of works:

const ROUTES: Routes = [
    {path: "buildings", children: [
        {path: "", component: BuildingListComponent, pathMatch: "full", outlet: "primary"},
        {path: "", component: BuildingListToolbarComponent, pathMatch: "full", outlet: "toolbar"},
        {path: ":id", component: BuildingDashboardComponent, outlet: "primary"}
    ]}
];

The idea is that the router would pick the matching path per outlet. But (nooo, it could have been so easy) unfortunately, this doesn't work:

const ROUTES: Routes = [
    {path: "buildings", children: [
        {path: "list", component: BuildingListComponent, pathMatch: "full", outlet: "primary"},
        {path: "list", component: BuildingListToolbarComponent, pathMatch: "full", outlet: "toolbar"},
        {path: ":id", component: BuildingDashboardComponent, outlet: "primary"}
    ]}
];

It appearently (and maybe accidentially) only works with an empty path. Why, oh why?

D) A complete different strategy would be to rework my component hierarchy, so that every main view component contains a appropriate toolbar, and use multi-slot content projection. Haven't tried this, but I'm afraid running into problems with multiple instances of the toolbar.

As sometimes, this seems to be a common use case, and I'm wondering how Angular 2 experts would solve this. Any ideas?

4
You could just add components dynamically like shown in stackoverflow.com/questions/36325212/… Inject the router to get notified about route changes and then add a matching component. You can also add data to routes to configure at route level what component should be added like demonstrated in stackoverflow.com/questions/38644314/…Günter Zöchbauer
Thank you Günter, I will try this. I also tried another variant of the router configuration, which looked promising (C), but no luck.Zeemee
I posted an answer based on your suggestion. Again, thank you for putting me to the right direction.Zeemee
Thanks @Zeemee that helps. It would help much more if you put a sample for a toolbar component.Hussain

4 Answers

18
votes

As suggested by Günter Zöchbauer (thank you!), I ended up adding and removing dynamic components to the toolbar. The desired toolbar component is specified in the data attribute of the route and evaluated by the central component (navbar) that contains the toolbar.
Note that the navbar component doesn't need to know anything about the toolbar components (which are defined in the feauture modules).
Hope this helps someone.

buildings-routing.module.ts

const ROUTES: Routes = [
    {path: "buildings", children: [
        {
            path: "",
            component: BuildingListComponent,
            pathMatch: "full",
            data: {toolbar: BuildingListToolbarComponent}
        },
        {
            path: ":id",
            component: BuildingDashboardComponent,
            data: {toolbar: BuildingDashboardToolbarComponent}
        }
    ]}
];

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

navbar.component.html

<div class="navbar navbar-default navbar-static-top">
    <div class="container-fluid">
        <form class="navbar-form navbar-right">
            <div #toolbarTarget></div>
        </form>
    </div>
</div>

navbar.component.ts

@Component({
    selector: 'navbar',
    templateUrl: './navbar.component.html',
    styleUrls: ['./navbar.component.scss']
})
export class NavbarComponent implements OnInit, OnDestroy {

    @ViewChild("toolbarTarget", {read: ViewContainerRef})
    toolbarTarget: ViewContainerRef;

    toolbarComponents: ComponentRef<Component>[] = new Array<ComponentRef<Component>>();
    routerEventSubscription: ISubscription;


    constructor(private router: Router,
                private componentFactoryResolver: ComponentFactoryResolver) {
    }

    ngOnInit(): void {
        this.routerEventSubscription = this.router.events.subscribe(
            (event: Event) => {
                if (event instanceof NavigationEnd) {
                    this.updateToolbarContent(this.router.routerState.snapshot.root);
                }
            }
        );
    }

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

    private updateToolbarContent(snapshot: ActivatedRouteSnapshot): void {
        this.clearToolbar();
        let toolbar: any = (snapshot.data as {toolbar: Type<Component>}).toolbar;
        if (toolbar instanceof Type) {
            let factory: ComponentFactory<Component> = this.componentFactoryResolver.resolveComponentFactory(toolbar);
            let componentRef: ComponentRef<Component> = this.toolbarTarget.createComponent(factory);
            this.toolbarComponents.push(componentRef);
        }
        for (let childSnapshot of snapshot.children) {
            this.updateToolbarContent(childSnapshot);
        }
    }

    private clearToolbar() {
        this.toolbarTarget.clear();
        for (let toolbarComponent of this.toolbarComponents) {
            toolbarComponent.destroy();
        }
    }
}

References:
https://vsavkin.com/angular-router-understanding-router-state-7b5b95a12eab
https://engineering-game-dev.com/2016/08/19/angular-2-dynamically-injecting-components
Angular 2 dynamic tabs with user-click chosen components
Changing the page title using the Angular 2 new router

4
votes

I don't really like and cannot use the proposed solutions. The central problem seems to be that the toolbar buttons and the actual component are two distinct components. When two components have to communicate the trouble starts:

My initial problem was a refresh button: the click on the button should reload data from an API (which then held in the component). How would the button in Component A be able to tell Component B to refresh?

My solution only uses one Component and keeps the toolbar actions in a ng-template in the template:

<ng-template #toolbaractions>
  <button (click)="refresh()">refresh</button>
</ng-template>

The Component looks like this:

export class S3BrowsePageComponent implements AfterViewInit {

    @ViewChild('toolbaractions', { read: TemplateRef })
    public toolbaractions: TemplateRef<any>;

    menu = new BehaviorSubject<TemplateRef<any>>(null);

    ngAfterViewInit(): void {
        this.menu.next(this.toolbaractions)
    }
    ...

Now just have to display the template once the Component is active. I decided to achieve that by using the activate and deactivate events on the router outlet of the enclosing Component (that provides the toolbar):

<toolbar>
   <ng-container [ngTemplateOutlet]="menu"></ng-container>
</toolbar>

<sidenav>...</sidenav>

<maincontent>
    <router-outlet (activate)='onActivate($event)'
               (deactivate)='onDeactivate($event)'></router-outlet>
</maincontent>

The activate function gets the Component instance as $event and you can check if the Component has any toolbar buttons:

onActivate($event: any) {
  console.log($event);
  if ($event.hasOwnProperty('menu')) {
    this.menuSubscription = $event['menu']
      .subscribe((tr: TemplateRef<any>) => this.menu = tr)
    }
  }
}

onDeactivate($event: any) {
  this.menu = null;
  this.menuSubscription.unsubscribe();
}
1
votes

This may be a little late (and may not answer the original question perfectly), but as all other solutions I found were quite complicated, I hope this could help someone in the future.
I was looking for an easy way to change the content of my toolbar depending on the page (or route) I'm on.
What I did was: put the toolbar in its own component, and in the HTML, create a different version of the toolbar for every page but only display the one that matches the current route:

app-toolbar.component.html

<mat-toolbar-row class="toolbar" *ngIf="isHomeView()">
    <span class="pagetitle">Home</span>
    <app-widget-bar></app-widget-bar>
</mat-toolbar-row>

<mat-toolbar-row class="toolbar" *ngIf="isLoginView()">
  <span class="pagetitle">Login</span>
  <app-login-button></app-login-button>
</mat-toolbar-row>

As you see, I embedded other components like the widget-bar and the login-button into the toolbar, so the styling and logic behind that can be in those other components and does not have to be in the toolbar-component itself. Depending on the ngIf, it is evaluated which version of the toolbar is displayed. The functions are defined in the app-toolbar.component.ts:

app-toolbar.component.ts

import { Component } from '@angular/core';
import { Router } from '@angular/router';

@Component({
  selector: 'app-toolbar',
  templateUrl: './app-toolbar.component.html',
  styleUrls: ['./app-toolbar.component.scss']
})
export class ToolbarComponent {

  constructor( private router: Router ) {

  }

  isHomeView() {
    // return true if the current page is home
    return this.router.url.match('^/$');
  }

  isLoginView() {
    // return true if the current page is login
    return this.router.url.match('^/login$');
  }

}

You can then embed the toolbar into another component (app, or dashboard or whatever):

<app-toolbar></app-toolbar>

This approach probably has some downsides, but it works quite well for me and I find it much easier to implement and understand than other solutions I found while researching.

0
votes

My solution for Angular 6

I had some issues with lazy loading modules that eventually were solved by adding my dynamic components to a shared module that I could load to the app. This is probably not very component-y, but nothing else I tried solved it. This seems to be a bit of a common issue that I saw over SO and Github. I had to read through here and here but didn't come up with a 100% solution. Based on Zeeme's answer above and the links to Günter Zöchbauer's answers, I was able to implement this in a project.

In any case, my major problem was that I couldn't always get the route's dynamic component that I wanted to load into my List-Detail route. I had a route like /events and then a child like /events/:id. When I navigated to something like /events/1234 by typing localhost:4200/events/1234 directly into the URL bar, the dynamic component wouldn't load right away. I had to click into a different list item in order for my toolbar to load. For example, I would have to navigate to localhost:4200/events/4321 and then the toolbar would load.

My fix is below: I used ngOnInit() to call this.updateToolbar with this.route.snapshot right away which allowed me to use the ActivatedRoute route immediately. Since ngOnInit is only called once, that first call to this.updateToolbar was only called once and then my Subscription was called on subsequent navigation. For whatever reason that I don't completely understand, the .subscribe() wasn't being triggered on my first navigation, so I then used subscribe to manage a subsequent change to a child route. My Subscription updates only once since I used .pipe(take(1)).... If you just use .subscribe() the updates will continue with each route change.

I had a List-Detail view going on and needed the List to get my current route.

import { ParamMap } from '@angular/router';

import { SEvent, SService } from '../s-service.service';

import { Observable } from 'rxjs';
import { switchMap, tap } from 'rxjs/operators';

import { Component, OnInit, OnDestroy, ViewChild, ViewContainerRef, ComponentRef, ComponentFactory, ComponentFactoryResolver, Type  } from '@angular/core';

import { SubscriptionLike } from 'rxjs';
import { Router, NavigationEnd, ActivatedRouteSnapshot, ResolveStart, ChildActivationEnd, ActivatedRoute } from '@angular/router';
import { Event } from '@angular/router';
import { filter, take } from 'rxjs/operators';

@Component({
  selector: 'app-list',
  templateUrl: './s-list.component.html',
  styleUrls: ['./s-list.component.scss']
})
export class SListComponent implements OnInit, OnDestroy {

  isActive = false;

  @ViewChild("toolbarTarget", {read: ViewContainerRef}) toolbarTarget: ViewContainerRef;

  toolbarComponents: ComponentRef<Component>[] = new Array<ComponentRef<Component>>();
  routerEventSubscription: SubscriptionLike;

    seismicEvents: Observable<SEvent[]>;
    selectedId: number;

   constructor(private service: SService,
    private router: Router,
    private route: ActivatedRoute,
    private componentFactoryResolver: ComponentFactoryResolver) { }

    ngOnInit() {
        this.sEvents = this.route.paramMap.pipe(
            switchMap((params: ParamMap) => {
                    this.selectedId = +params.get('id');
                    return this.service.getSEvents();
                })
      );

      // used this on component init to trigger updateToolbarContent 
      this.updateToolbarContent(this.route.snapshot);

      // kept this like above (with minor modification) to trigger subsequent changes
      this.routerEventSubscription = this.router.events.pipe(
        filter(e => e instanceof ChildActivationEnd),
        take(1)
      ).subscribe(
        (event: Event) => {
            if (event instanceof ChildActivationEnd) {
              this.updateToolbarContent(this.route.snapshot);
            }
        }
      );
   }

  ngOnDestroy() {
    this.routerEventSubscription.unsubscribe();
  }

  private clearToolbar() {
    this.toolbarTarget.clear();
    for (let toolbarComponent of this.toolbarComponents) {
        toolbarComponent.destroy();
    }
  }

  private updateToolbarContent(snapshot: ActivatedRouteSnapshot) {

    // some minor modifications here from above for my use case

    this.clearToolbar();
    console.log(snapshot);
    let toolbar: any = (snapshot.data as {toolbar: Type<Component>}).toolbar;
    if (toolbar instanceof Type) {
      let factory: ComponentFactory<Component> = this.componentFactoryResolver.resolveComponentFactory(toolbar);
      let componentRef: ComponentRef<Component> = this.toolbarTarget.createComponent(factory);
      this.toolbarComponents.push(componentRef);
    }
  }
}