Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Angular2: Display different "toolbar" components depending on main navigation

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?

like image 490
Zeemee Avatar asked Dec 08 '16 10:12

Zeemee


3 Answers

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

like image 65
Zeemee Avatar answered Nov 14 '22 03:11

Zeemee


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();
}
like image 25
Sebastian Annies Avatar answered Nov 14 '22 04:11

Sebastian Annies


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.

like image 1
DocRobson Avatar answered Nov 14 '22 03:11

DocRobson