Quantcast
Channel: Candor Developer
Viewing all articles
Browse latest Browse all 37

How to create dynamic menu and page title with Angular Material and CLI

0
0

Adding Material Design to an Angular CLI project is relatively simple; Connecting your routed page components to a menu with navigation and bookmarking is a little more complex. The solution has a common pattern you can follow using built in angular router and some custom route inspection code. Continuing from a previous article [^] to add Angular CLI to a Visual Studio project that created a blank project shell, we will create a routed application with a Material design appearance. The steps shown also work in Webstorm, VS Code, and any other IDE with NPM support you use for Angular projects today.

The project organization

Some simple projects may be fine just adding a few components to the AppModule and have a completed app, but business scale apps will include multiple application areas. They may be connected by a common app menu, or by accessing functions within each component. I refer to each of these application areas a ‘page component’, which in reality is just a normal component except that it is responsible for the whole content area of the page. This application will consist of 3 main page components

Each page component will be contained within a module with routing. The navigation component included at the top of each page component will read the routing to build the menu. You can also add other menu items, such as for linking outside the SPA into another umbrella site.

Prerequisites

See the previous article if you are working in a Visual Studio project. This article will work for anyone in other editors as well. I vary between WebStorm and Visual Studio 2015 community and Visual Studio 2017 professional.

Ensure NPM, node, and Angular CLI are installed. Open the command prompt and ensure npm and the cli commands are available in your path. just type ‘npm’ and enter to get the help output to verify npm is available, and then ‘ng’ and enter to see the angular cli help and verify it is available for use. I am using the current latest version of each, NPM version 3.10.8, node 6.10.2, and Angular CLI version 1.0.0.

You’ll see the “ng g” command referenced frequently below, this is just an alias for “ng generate”. You can type “g” or “generate”, whichever puts your mind in the right place. The “ng –help” output tells you the alias for each command. I will only use the alias for ‘generate’ below to make it easier to read the commands, since ‘ng g m” and “ng g c” and “ng g s” may be a little too cryptic.

The lines of console commands will start with “C:\…\”. The three dots aren’t in the actual console, I’ve cut the line length down so you can read the commands. Your console will show your full path.

Creating page component routes with Angular CLI

Its easy to create a new component and module following the style guide best practices, plus the command hooks up the newly created components to the right module. Start by creating a page component module. For detailed help type the “ng help” + enter command.

C:\...\candor-sample-ng-cli\src\app>ng g module trip --flat false --routing
installing module
  create src\app\trip\trip-routing.module.ts
  create src\app\trip\trip.module.ts

This command names the module ‘trip’, sets flat to false so that a directory is created for the module, and the routing switch adds the routing module. be aware of the folder where you run this command, as that folder will be the root under which your files are created. This only creates/modifies the files listed in the output, so as you can see this does not add the module to the AppModule.

Now that we have a module for this application area, lets create a component for the page. We’re going to organize the trip module components under the trip folder, so first switch to that directory then run the generate component command.

C:\...\candor-sample-ng-cli\src\app>cd trip

C:\...\candor-sample-ng-cli\src\app\trip>ng g component trip-page
installing component
  create src\app\trip\trip-page\trip-page.component.scss
  create src\app\trip\trip-page\trip-page.component.html
  create src\app\trip\trip-page\trip-page.component.spec.ts
  create src\app\trip\trip-page\trip-page.component.ts
  update src\app\trip\trip.module.ts

This command added the newly generated component to the trip module. It does this by traversing up the folder tree to the nearest folder containing a module, then adds itself to that module. The folder you run CLI commands in matters. You can skip the module import with the ‘skip-import’ option if you so choose.

Building a menu service

Our page components are all going to render a menu for app routes so the user can navigate between app components. Wouldn’t it be nice if the app just knew what modules we had and added them to the menu automatically in a predictable way? Well, it is easy to do just that. Our project already has node module “@angular/router”, added when we generated the project with routing enabled. See the prior article for how we arrived at this point.

Start by creating a ‘service’ using the ng generate command.

C:\...\candor-sample-ng-cli\src\app>ng g service app-toolbar --flat false
installing service
  create src\app\app-toolbar\app-toolbar.service.spec.ts
  create src\app\app-toolbar\app-toolbar.service.ts
  WARNING Service is generated but not provided, it must be provided to be used

The service name is ‘app-toolbar’ but generates the typescript class name of “AppToolbarService”. It’s recommended to generate file names in all lower case with dashes separating words, and CLI will fix that for you when defining the class names. The ‘flat’ switch is set to false to ensure a directory is created.

We left off the ‘module’ flag which would have set the module to ‘provide the service’ in. The output tells us that much, but what can we do about it? The warning gave us a clue, lets edit the AppModule and provide the service. Why the AppModule? I’ve planned ahead and know which component is going to host our toolbar, which will be the AppComponent. If we were going to use it directly in one of the Trip components then we would provide it there.

// src/app/app.module.ts
...
import { AppComponent } from './app.component';
import { AppToolbarService } from './app-toolbar/app-toolbar.service';

@NgModule({
    declarations: [
        AppComponent
    ],
    imports: [
        ...
    ],
    providers: [AppToolbarService],
    bootstrap: [AppComponent]
})
export class AppModule { }

The toolbar service implementation

The toolbar service implementation should expose the menu items from the router and keep track of the active menu item as it changes when the user navigates. Todd Motto has a great article on the details (see references below). I’ve extended it a little bit to add an option for a material design icon name for each menu item.

// src/app/app-toolbar/app-toolbar.service.ts
import { Injectable } from '@angular/core';
import { Router, NavigationEnd, ActivatedRoute } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/operator/filter';
import 'rxjs/add/operator/map';

export class MenuItem {
    path: string;
    title: string;
    icon?: string;
}

@Injectable()
export class AppToolbarService {
    activeMenuItem$: Observable<MenuItem>;

    constructor(private router: Router, private titleService: Title) {
        this.activeMenuItem$ = this.router.events
            .filter(e => e instanceof NavigationEnd)
            .map(_ => this.router.routerState.root)
            .map(route => {
                let active = this.lastRouteWithMenuItem(route.root);
                this.titleService.setTitle(active.title);
                return active;
            });
    }
    getMenuItems(): MenuItem[] {
        return this.router.config
            .filter(route => route.data && route.data.title) //only add a menu item for routes with a title set.
            .map(route => {
                return {
                    path: route.path,
                    title: route.data.title,
                    icon: route.data.icon
                };
            });
    }

    private lastRouteWithMenuItem(route: ActivatedRoute): MenuItem {
        let lastMenu = undefined;
        do { lastMenu = this.extractMenu(route) || lastMenu; }
        while ((route = route.firstChild));
        return lastMenu;
    }
    private extractMenu(route: ActivatedRoute): MenuItem {
        let cfg = route.routeConfig;
        return cfg && cfg.data && cfg.data.title
            ? { path: cfg.path, title: cfg.data.title, icon: cfg.data.icon }
            : undefined
    }
}

At this point no routes define a title attribute, so none will appear in the menu. So let’s update the Trip routing module to set a title. Most of this file was generated, so all we need to change is the route definition at the top.

// src/app/trip/trip-routing.module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { TripPageComponent } from './trip-page/trip-page.component';

const routes: Routes = [{
    path: 'trip',
    component: TripPageComponent,
    data: {
        title: 'Trip Finder'
    }
}];

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

With just one menu item, ordering code isn’t needed, but how can that be added later when more routes are added? The order of routes is predictable based on the order angular router finds the routes. The order angular router finds your routes is based on order of definition in your AppModule. So the simple way to set ordering is to change the order routes are specified. An alternative would be to add ordering information, similar to how ‘title’ and ‘icon’ was added and then order them in the AppToolbarService after filter but before map. For now I’ll stick to import ordering to control menu order.

 //app.module.ts
// typescript imports not shown, see full source for details.
@NgModule({
    declarations: [
        AppComponent
    ],
    imports: [
        //omitted unrelated modules
        AppCommonModule,
        AppRoutingModule,
        TripModule,
        TripRoutingModule,
        //Future: LocationModule,
        //Future: LocationRoutingModule,
        //Future: PersonModule,
        //Future: PersonRoutingModule,
        RouterModule.forRoot([{
            path: '', redirectTo: '/trip', pathMatch: 'full'
        }])
    ],
    providers: [AppToolbarService],
    bootstrap: [AppComponent]
})
export class AppModule { }

Here the app component puts the trip component first in the menu, then a comment indicates that location module and then person module will be next. We haven’t created these other modules yet in this article, but you can find them in the example project. The last import initializes the RouterModule while setting the default path to redirect to the trip module. The redirect is an optional step since we have no welcome page and instead we’ll just show the trip finder page.

The next step is to use this menu service in our layout to create a menu.

Building a Material design layout

At NgConf 2017 Elad Bezalel created a great set of instructions for a basic angular material layout (see references below). Elad’s quickstart covers the basics of Angular Material and how to put it in your project. The only problem is that it is based on a slightly out of date version of Material before the MaterialModule was obsolete. Lets fix that, and while we are at it lets use that sidenav to create a menu of our page components.

Create a module of Material components

The MaterialModule is obsolete so that you don’t add components you don’t need to your project, unnecessarily increasing the size of your application. They will be adding more components over time, inevitably you won’t be using them all. However, including each one only where it is needed will still result in code duplication, since many of the modules you will consider core to your application. This step is optional, you can instead add each of the components you need to the modules that use them.

C:\...\candor-sample-ng-cli\src\app>ng g module app-common --flat false
installing module
  create src\app\app-common\app-common.module.ts

You’ll need to import this generated module into others that use the components in it. In this project that means the app module, which will contain the side nav and the trip component which will use cards, inputs and more. Just add the AppCommonModule to the NgModule imports, not the declarations or exports.

// src/app/trip/trip.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AppCommonModule } from '../app-common/app-common.module';

import { TripRoutingModule } from './trip-routing.module';
import { TripPageComponent } from './trip-page/trip-page.component';

@NgModule({
  imports: [
    CommonModule,
    AppCommonModule,
    TripRoutingModule
  ],
  declarations: [TripPageComponent],
  exports: [
      TripPageComponent
  ]
})
export class TripModule { }

Make the same addition to AppModule. See the example repo for details.

Within the AppCommonModule lets add in all the layout components we will need in our app. Keep the list as short as possible. This list shown here does not include all available component modules, but may still be too many for some apps. Add each you will use to the module imports and exports. I’m adding some material modules and the angular flex-layout module.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { //only import the portions you will use to optimize build (MaterialModule to include all is deprecated)
      MdCoreModule,
      MdButtonModule,
      MdCardModule,
      MdIconModule,
      MdMenuModule,
      MdRippleModule,
      MdSidenavModule,
      MdToolbarModule,
      //... add others you need
} from '@angular/material';
import { FlexLayoutModule } from '@angular/flex-layout';

@NgModule({
  imports: [
      CommonModule,
      MdCoreModule,
      MdButtonModule,
      MdCardModule,
      MdIconModule,
      MdMenuModule,
      MdRippleModule,
      MdSidenavModule,
      MdToolbarModule,
      FlexLayoutModule,
      //... add others you need
  ],
  declarations: [],
  exports: [
      MdCoreModule,
      MdButtonModule,
      MdCardModule,
      MdIconModule,
      MdMenuModule,
      MdRippleModule,
      MdSidenavModule,
      MdToolbarModule,
      FlexLayoutModule,
      //... add others you need
  ]
})
export class AppCommonModule { }

Defining the layout with menu

This app will structure the master layout wrapping all the page modules in the app component along with a router-outlet with the content of each page component.

The basic structure of the page will be a main content area with a sidenav to the left side that for now is always open. A sidenav needs to be contained inside a sidenav container. The whole thing is placed inside an fxLayout with fxFlex to make it take up the full screen height.

 //app.component.html
<div fxLayout="column" fxFlex>
    <md-sidenav-container fxFlex>
        <md-sidenav mode="side" opened>
            ...
        </md-sidenav>
        ...
        <router-outlet></router-outlet>
    </md-sidenav-container> 
</div>

Now lets add to this by allowing the user to close the side nav and add a toolbar. This project will add a toolbar to the sidenav and another to the main content area. It will appear as a single toolbar but with a different color in the sidenav area. You can opt to only include a toolbar in the main area if you prefer. I use the toolbar in the side nav to show the app icon and name, but you can do that in the main content area. Try a few variations to see which you prefer.

 //app.component.html
<div fxLayout="column" fxFlex>
    <md-sidenav-container fxFlex>
        <md-sidenav #mainSideNav mode="side" opened>
            ...
        </md-sidenav>
        <md-toolbar color="primary">
            <button md-icon-button (click)="mainSideNav.toggle()">
                <md-icon *ngIf="mainSideNav.opened">chevron_left</md-icon>
                <md-icon *ngIf="!mainSideNav.opened">menu</md-icon>
            </button>
            <!--<md-icon *ngIf="navItem.icon">{{navItem.icon}}</md-icon>-->
            {{(activeMenuItem$ | async)?.title}}
        </md-toolbar>
        <router-outlet></router-outlet>
    </md-sidenav-container> 
</div>

The above layout added a button to the main area toolbar to close and open the sidenav. This is done using a template variable #mainSideNav, not defined in the component typescript. The button click triggers the toggle function defined on the sidenav component, again not a function in the component typescript.

The view does show the active menu item as defined in the component typescript. It pulls the title from the active menu item, and since it is an observable as you will see below this will update anytime the router switches to another active route. The icon is stubbed in, but not yet used.

Now for the final version, lets add the menu items to the main sidenav.

 //app.component.html
<div fxLayout="column" fxFlex>
    <md-sidenav-container fxFlex>
        <md-sidenav #mainSideNav mode="side" opened>
            <md-toolbar>
                <img src="../assets/logo.png" style="height:80%"> {{appName}}
            </md-toolbar>
            <md-nav-list>
                <a *ngFor="let navItem of mainMenuItems" 
                   md-list-item
                   md-ripple
                   [style.position]="'relative'"
                   routerLinkActive="selected"
                   [routerLink]="[navItem.path]">
                    <!--<md-icon *ngIf="navItem.icon">{{navItem.icon}}</md-icon>-->
                    <span>{{navItem.title}}</span>
                </a>
            </md-nav-list>
        </md-sidenav>
        <md-toolbar color="primary">
            <button md-icon-button (click)="mainSideNav.toggle()">
                <md-icon *ngIf="mainSideNav.opened">chevron_left</md-icon>
                <md-icon *ngIf="!mainSideNav.opened">menu</md-icon>
            </button>
            <!--<md-icon *ngIf="navItem.icon">{{navItem.icon}}</md-icon>-->
            {{(activeMenuItem$ | async)?.title}}
        </md-toolbar>
        <router-outlet></router-outlet>
    </md-sidenav-container> 
</div>

The template above uses some variables to be defined by the component, ‘appName’, ‘mainMenuItems’, and ‘activeMenuItem$’.

 //app.component.ts
import { Component } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { AppToolbarService, MenuItem } from './app-toolbar/app-toolbar.service';

@Component({
  selector: 'body',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
    appName = 'Ride Finder';
    mainMenuItems;
    activeMenuItem$: Observable<MenuItem>;

    constructor(private toolbarService: AppToolbarService) {
        this.mainMenuItems = this.toolbarService.getMenuItems();
        this.activeMenuItem$ = this.toolbarService.activeMenuItem$;
    }
}

We’ve injected the AppToolbarService and called the public members to get the menu items (one time call), and gathered a reference to the observable of the active menu item. Since activeMenuItem is an observable I named it with a dollar sign at the end, a common convention for observable variables. This convention is not required, but is recommended.

For our trip component template, let’s add what will be the main content area when that route is active. The design should be responsive, so for now I’ll create a layout mimicking a grid layout with a main card and an aside card to the right, then when the screen is small the aside card will jump to below.

 //trip-page.component.html
<div class="content" fxLayout="row" fxLayout.sm="column" fxLayout.xs="column" fxLayoutGap="16px">
    <md-card fxFlex="80">
        trip component works!
    </md-card>

    <md-card fxFlex fxLayout="column" fxLayoutGap="14px">
        aside
    </md-card>
</div>

The main content div defines an fxLayout of row so the default flow will show the 2 cards side by side. On the extra small (fxLayout.xs) and small (fxLayout.sm) layout width screens the fxLayout will toggle to ‘column’ so the cards stack. The two cards are defined inside this div with the first card defining a percentage width it will take (when in row layout). The fxLayoutGap attributes define a space between cards.



Wrap Up

This sample contains a single routed page with material design layouts and a dynamic menu and page title. In the example repo there are also two other routes in the menu. Each of the three page components are defined with similar Angular CLI commands. Future steps within one module would be to add more components for the various sections of a page such as search form, search results, and an item detail. You can see the general idea by looking at the other non-CLI, systemJS based web project in the same repository.

References

Example project repository
https://github.com/michael-lang/sample-ng2-mvc

Todd Motto: Dynamic page titles in Angular 2 with router events
https://toddmotto.com/dynamic-page-titles-angular-2-router-events

Elad Bezalel: Quick Starter Repository for Angular Material 2
https://github.com/EladBezalel/material2-start


Viewing all articles
Browse latest Browse all 37

Latest Images

Trending Articles





Latest Images