Writing an Angular Schematic to add a Lazy Loaded Feature Route

·

19 min read


title: Writing an Angular Schematic to add a Lazy Loaded Feature Route published: true date: 2019-06-11 17:50:45 UTC tags: angular-schematics,nx,typescript,angular

canonical_url:

Make life easier with Angular Schematics…

Obligatory picture of code…

What is an Angular Schematic?

Angular Schematics enable you to automatically generate consistently formatted code. For more information see the official Angular guide on schematics.

Angular

Manfred Steyer also has an excellent series on the topic here.

The Goal

Write a schematic that will create a feature module (with routing), a component, add the new feature module to the app-routing module (lazily loaded), and (optionally) add pre-defined guards and pre-defined navigation items.

In this article I’ll provide all the commands used to generate the project.

Getting Started

The repo for this project is here: github.com/martzcodes/medium-schematic

Angular 8 is officially out, so we’ll be using it in combination with Nx.

To generate the initial project I used:

npm init nx-workspace medium
# selected CSS, martzcodes, empty
ng add @nrwl/workspace
ng add @nrwl/angular # Adds Angular capabilities
ng g [@nrwl/angular](http://twitter.com/nrwl/angular):library core # store the guards and other stuff
ng g app qwerty --framework="angular" --routing --inline-style --inline-template --skip-tests # template application to be copied from
ng g guard dummy --project=core

There are a few design decisions being used in this project… I wouldn’t necessarily use them in practice but it simplifies some things. In the case of lazy-loaded routes they could very well be maintained in their own library, and then that gets included and shared between multiple applications. For a demo project like this it’s overkill, but the same concepts could be applied if you do have applications that split everything into libraries.

Run ng serve qwerty to make sure the app starts with the default Nx page.

Now that the basic scaffold is set up… let’s tweak some things.

The DummyGuard that gets generated doesn’t do anything… let’s just have it return true. You’d add whatever guard logic you want in here…

import { Injectable } from '[@angular/core](http://twitter.com/angular/core)';
import { CanLoad, UrlSegment, Route } from '[@angular/router](http://twitter.com/angular/router)';
import { Observable } from 'rxjs';

[@Injectable](http://twitter.com/Injectable)({
  providedIn: 'root'
})
export class DummyGuard implements CanLoad {
  constructor() {}

canLoad(
    route: Route,
    segments: UrlSegment[]
  ): boolean | Promise<boolean> | Observable<boolean> {
    return true;
  }
}

Also, in libs/core/src/index.ts add export * from './lib/dummy.guard';

And… I like my app-routing to be outside my main app module… so let’s split that out.

Create apps/qwerty/src/app/app-routing.module.ts and add the following code:

import { NgModule } from '[@angular/core](http://twitter.com/angular/core)';
import { Routes, RouterModule } from '[@angular/router](http://twitter.com/angular/router)';

const routes: Routes = [];

[@NgModule](http://twitter.com/NgModule)({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule {}

And make the corresponding change in apps/qwerty/src/app/app.module.ts:

import { BrowserModule } from '[@angular/platform-browser](http://twitter.com/angular/platform-browser)';
import { NgModule } from '[@angular/core](http://twitter.com/angular/core)';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module'; // added
// unused imports removed

[@NgModule](http://twitter.com/NgModule)({
  declarations: [AppComponent],
  imports: [BrowserModule, AppRoutingModule], // updated
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule {}

While we’re at it… let’s make the app component slightly different…

import { Component } from '[@angular/core](http://twitter.com/angular/core)';

[@Component](http://twitter.com/Component)({
  selector: 'martzcodes-root',
  template: `
    <div style="text-align:center">
      <h1>Welcome to {{ title }}!</h1>
      <pre>{{ navigation | json }}</pre>
      <router-outlet></router-outlet>
</div>
  `,

styles: []
})
export class AppComponent {
  title = 'qwerty';
  navigation: any[];

constructor() {
    this.navigation = [];
  }
}

ng serve qwerty should still work… there just won’t be much to look at.

Now… let’s make a schematic.

Run ng g workspace-schematic feature-route

workspace-schematic is an Nx-provided schematic that creates a schematic template in tools/schematics/

Before we try it out, let’s tweak some things…

For tools/schematics/feature-route/schema.json make it look like this:

{
  "$schema": "[http://json-schema.org/schema](http://json-schema.org/schema)",
  "id": "feature-route",
  "type": "object",
  "properties": {
    "project": {
      "type": "string",
      "description": "Project name",
      "x-prompt": "Which project?"
    },
    "feature": {
      "type": "string",
      "description": "Feature name",
      "x-prompt": "What's the feature route?"
    },
    "dummy": {
      "type": "boolean",
      "description": "Dummy Guard",
      "default": true,
      "x-prompt": "Should the route use the Dummy Guard?"
    },
    "navigation": {
      "type": "boolean",
      "description": "Navigation",
      "default": true,
      "x-prompt": "Should this be added to the site's navigation?"
    }
  },
  "required": ["project", "feature"]
}

This gets rid of using the argument values and makes everything an interactive prompt (personal preference on my part).

Nx doesn’t add a schema interface… so let’s add one. Create tools/schematics/feature-route/schema.ts and add the following:

export default interface Schema {
  project: string;
  feature: string;
  dummy: string;
  navigation: boolean;
}

Now let’s start playing with the actual schematic… the tools/schematics/feature-route/index.ts file.

Nx looks for the index.ts file and uses the default function that is exported to run the schematics. The rest follows the Angular conventions that they have in their schematics API. It’s a little bare to begin with, so let’s extend it a little.

Let’s get the imports out of the way… these are all of them… no need to go back and forth.

import {
  chain,
  Rule,
  SchematicContext,
  Tree,
  SchematicsException,
  externalSchematic
} from '[@angular](http://twitter.com/angular)-devkit/schematics';
import { getWorkspacePath, readJsonInTree } from '[@nrwl/workspace](http://twitter.com/nrwl/workspace)';
import Schema from './schema';
import { strings } from '[@angular](http://twitter.com/angular)-devkit/core';
import \* as ts from 'typescript';
import {
  insertImport,
  getSourceNodes,
  InsertChange
} from '[@nrwl/workspace](http://twitter.com/nrwl/workspace)/src/utils/ast-utils';

Now… Let’s define a NormalizedSchema which will extend the previously defined schema and a function that will do the normalization

interface NormalizedSchema extends Schema {
  appProjectRoot: string;
  // Other things could go in here...
}

function normalizeOptions(host: Tree, options: Schema): NormalizedSchema {
  const appProjectRoot = `apps/${options.project}`;

return {
    ...options,
    appProjectRoot
  };
}

This is a convenience thing… it’s very easily to extend without cluttering up your input schema. To use it we need to first use the original Schema we created in the export default function and then extend it… so the new default function becomes…

export default function(schema: Schema): Rule {
  return (host: Tree, context: SchematicContext) => {
    const options = normalizeOptions(host, schema);
    const angularJson = readJsonInTree(host, getWorkspacePath(host));
    if (!angularJson.projects[options.project]) {
      throw new SchematicsException(
        `Project ${options.project} is not in angular.json file!`
      );
    }
    return chain([
         // ...
    ]);
  };
}

This also shows an example of throwing an error . It takes in the options provided by the input interface, normalizes them and uses them moving forward.

Now that the intial schematic setup is done… let’s break this down to smaller chunks…

External Schematics

Nx starts it with an external schematic that run’s Nx’s library schematic. While we do want to use external schematics… we DON’T want to create another library (at least not now). Replace their external schematic with:

externalSchematic('[@schematics/angular](http://twitter.com/schematics/angular)', 'module', {
  project: options.project,
  name: `${options.feature}`,
  routing: true
}),
externalSchematic('[@schematics/angular](http://twitter.com/schematics/angular)', 'component', {
  project: options.project,
  name: `${options.feature}/${options.feature}-container`,
  changeDetection: 'OnPush'
}),

Adding the Feature Component to the Feature Route

Now that we have a module with routing and a component… let’s add a container component to the route. We’ll need to create a Rule for the Angular Schematics to make two changes to the feature-routing file. It’ll need to import the feature component and add a route to the list of routes. Start with a function…

function addComponentToRoute(options: NormalizedSchema): Rule {
  return (host: Tree) => {
      return host;
    }
  };
}

This won’t do anything yet but it’s a good starting snippet. Notice that the input to it are the options that we normalized earlier. Once done this will go in the chain of the default function.

First let’s define some constants…

const featureRoutingPath = `${
    options.appProjectRoot
  }/src/app/${strings.dasherize(options.feature)}/${strings.dasherize(
    options.feature
  )}-routing.module.ts`;

const containerComponent = `${strings.capitalize(options.feature)}ContainerComponent`;

const route = `{
    path: '',
    pathMatch: 'full',
    component: ${containerComponent}
  }`;

Then we’ll read in the typescript file for the routing path and read the nodes…

// tslint:disable-next-line
const featureRouting = host.read(featureRoutingPath)!.toString('utf-8');

const src = ts.createSourceFile(
  `${strings.dasherize(options.feature)}-routing.module.ts`,
  featureRouting,
  ts.ScriptTarget.Latest,
  true
);
const nodes = getSourceNodes(src);

getSourceNodes is provided by some Nrwl.io utilities… it takes in the typescript source and outputs the Nodes from the Abstract Syntax Tree (AST). This post gives some good background on using Schematics and how to navigate the AST.

For example… in this file we want to find the routes array. Our next step will be finding the Node that does a VariableDeclaration and has an identifier named routes:

const routeNodes = nodes
  .filter((n: ts.Node) => {
    if (n.kind === ts.SyntaxKind.VariableDeclaration) {
      if (
        n.getChildren().findIndex(c => {
          return (
            c.kind === ts.SyntaxKind.Identifier && c.getText() === 'routes'
          );
        }) !== -1
      ) {
        return true;
      }
    }
    return false;
  })
  .map((n: ts.Node) => {
    const arrNodes = n
      .getChildren()
      .filter(c => (c.kind = ts.SyntaxKind.ArrayLiteralExpression));
    return arrNodes[arrNodes.length - 1];
  });

This is a key concept! These types of patterns are used a lot in Angular Schematics and are some of the more difficult to use (it’s not super well-documented). This section of code takes the list of nodes and filters them down to VariableDeclarations that include an Identifier with the text routes. We then map to the ArrayLiteralExpression which is the actual list itself.

Next we need to actually insert the changes to the file…

if (routeNodes.length === 1) {
  const navigation: ts.ArrayLiteralExpression = routeNodes[0] as ts.ArrayLiteralExpression;
  const pos = navigation.getStart() + 1;
  const fullText = navigation.getFullText();
  let toInsert = '';
  if (navigation.elements.length > 0) {
    if (fullText.match(/\r\n/)) {
      toInsert = `${fullText.match(/\r\n(\r?)\s*/)[0]}${route},`;
    } else {
      toInsert = `${route},`;
    }
  } else {
    toInsert = `${route}`;
  }

const recorder = host.beginUpdate(featureRoutingPath);
  recorder.insertRight(pos, toInsert);

const componentChange = insertImport(
    src,
    featureRoutingPath,
    `${containerComponent}`,
    `./${strings.dasherize(options.feature)}-container/${strings.dasherize(
      options.feature
    )}-container.component`,
    false
  );
  if (componentChange instanceof InsertChange) {
    recorder.insertLeft(
      (componentChange as InsertChange).pos,
      (componentChange as InsertChange).toAdd
    );
  }

host.commitUpdate(recorder);
  return host;
}

We check to make sure only one node was found (in practice we would probably throw an error if more than one variable was found, but this will work for now). There are two updates that are going to be made. We need to add the import statement for the Component and then add the actual route that uses the imported component. Nrwl.io provides an insertImport method where you need to supply some basic information and it will generate the change needed to add the import.

Next, we find the start position of the Array node (basically the character position in the document). Check to see if any other routes already exist and then write the inserts. Some of the Angular Schematic specific code is the creation of the recorder. host.beginUpdate is how Angular Schematics track the changes to files. record.insertRight adds the change and host.commitUpdate confirms it so that when the schematic actually goes to execute the changes, it follows these changes.

Adding the Feature Route to the App Router (lazy-loaded)

We now have a route in the feature module but no way to view the route. We’ll need to change the app-routing.module.ts file to add a lazily-loaded route and check whether or not we wanted the feature to use the dummy guard or not… which boils down to optionally adding an import and injecting a route into a list of routes. We’ve already done both of these! The function ends up looking like this:

function addRouteToApp(options: NormalizedSchema): Rule {
  return (host: Tree) => {
    const appRoutingPath = `${
      options.appProjectRoot
    }/src/app/app-routing.module.ts`;

// tslint:disable-next-line
    const appRouting = host.read(appRoutingPath)!.toString('utf-8');

const src = ts.createSourceFile(
      'app-routing.module.ts',
      appRouting,
      ts.ScriptTarget.Latest,
      true
    );

const route = `{
      path: '${options.feature}',
      loadChildren: './${strings.dasherize(
        options.feature
      )}/${strings.dasherize(options.feature)}.module#${strings.capitalize(
      options.feature
    )}Module'${
      options.dummy
        ? `,
      canLoad: [DummyGuard]`
        : ''
    }
    }`;

const nodes = getSourceNodes(src);
    const routeNodes = nodes
      .filter((n: ts.Node) => {
        if (n.kind === ts.SyntaxKind.VariableDeclaration) {
          if (
            n.getChildren().findIndex(c => {
              return (
                c.kind === ts.SyntaxKind.Identifier && c.getText() === 'routes'
              );
            }) !== -1
          ) {
            return true;
          }
        }
        return false;
      })
      .map((n: ts.Node) => {
        const arrNodes = n
          .getChildren()
          .filter(c => (c.kind = ts.SyntaxKind.ArrayLiteralExpression));
        return arrNodes[arrNodes.length - 1];
      });

if (routeNodes.length === 1) {
      const navigation: ts.ArrayLiteralExpression = routeNodes[0] as ts.ArrayLiteralExpression;
      const pos = navigation.getStart() + 1;
      const fullText = navigation.getFullText();
      let toInsert = '';
      if (navigation.elements.length > 0) {
        if (fullText.match(/\r\n/)) {
          toInsert = `${fullText.match(/\r\n(\r?)\s*/)[0]}${route},`;
        } else {
          toInsert = `${route},`;
        }
      } else {
        toInsert = `${route}`;
      }

const recorder = host.beginUpdate(appRoutingPath);
      recorder.insertRight(pos, toInsert);

if (options.dummy) {
        const authChange = insertImport(
          src,
          `${options.appProjectRoot}/src/app/app-routing.module.ts`,
          'DummyGuard',
          '[@martzcodes/core](http://twitter.com/martzcodes/core)',
          false
        );
        if (authChange instanceof InsertChange) {
          recorder.insertLeft(
            (authChange as InsertChange).pos,
            (authChange as InsertChange).toAdd
          );
        }
      }

host.commitUpdate(recorder);

return host;
    }
  };
}

There isn’t anything particularly interesting in here. For the route I am using the Angular 7 version of lazy loading instead of the new Angular 8 import method, so this is backwards compatible at least to Angular 7.

Adding the Navigation Item to the App Component

Next… adding the navigation item. This is possibly less interesting than the previous two sections… it simply adds a nav item object to the component’s navigation list. Notably if the navigation input was set to false it skips this Rule. The function looks like this:

function addNavigation(options: NormalizedSchema): Rule {
  return (host: Tree) => {
    if (!options.navigation) {
      return host;
    }
    const componentPath = `${options.appProjectRoot}/src/app/app.component.ts`;
    const navPath = `{name: '${strings.capitalize(
      options.feature
    )}', router: '/${options.feature}', unsecure: ${
      options.dummy ? 'false' : 'true'
    }}`;
    // tslint:disable-next-line
    const appComponent = host.read(componentPath)!.toString('utf-8');

const src = ts.createSourceFile(
      'app.component.ts',
      appComponent,
      ts.ScriptTarget.Latest,
      true
    );

const nodes = getSourceNodes(src);
    const navNodes = nodes
      .filter((n: ts.Node) => {
        if (n.kind === ts.SyntaxKind.BinaryExpression) {
          if (
            n.getChildren().findIndex(c => {
              return (
                c.kind === ts.SyntaxKind.PropertyAccessExpression &&
                c.getText() === 'this.navigation'
              );
            }) !== -1
          ) {
            return true;
          }
        }
        return false;
      })
      .map((n: ts.Node) => {
        const arrNodes = n
          .getChildren()
          .filter(c => (c.kind = ts.SyntaxKind.ArrayLiteralExpression));
        return arrNodes[arrNodes.length - 1];
      });

if (navNodes.length === 1) {
      const navigation: ts.ArrayLiteralExpression = navNodes[0] as ts.ArrayLiteralExpression;
      const pos = navigation.getEnd() - 1;
      const fullText = navigation.getFullText();
      let toInsert = '';
      if (navigation.elements.length > 0) {
        if (fullText.match(/\r\n/)) {
          toInsert = `,${fullText.match(/\r\n(\r?)\s*/)[0]}${navPath}`;
        } else {
          toInsert = `, ${navPath}`;
        }
      } else {
        toInsert = `${navPath}`;
      }

const recorder = host.beginUpdate(componentPath);
      recorder.insertLeft(pos, toInsert);
      host.commitUpdate(recorder);
    }

return host;
  };
}

Last Steps

Finally… add the rules to the default function’s chain after the external schematics…

return chain([
  externalSchematic('[@schematics/angular](http://twitter.com/schematics/angular)', 'module', {
    project: options.project,
    name: `${options.feature}`,
    routing: true
  }),
  externalSchematic('[@schematics/angular](http://twitter.com/schematics/angular)', 'component', {
    project: options.project,
    name: `${options.feature}/${options.feature}-container`,
    changeDetection: 'OnPush'
  }),
  addComponentToRoute(options),
  addRouteToApp(options),
  addNavigation(options)
]);

And now we can run the schematic. Run npm run workspace-schematic feature-route and answer the questions… for example:

$ npm run workspace-schematic feature-route

> medium@0.0.0 workspace-schematic C:\dev\medium
> nx workspace-schematic "feature-route"

? Which project? qwerty
? What's the feature route? asdf
? Should the route use the Dummy Guard? Yes
? Should this be added to the site's navigation? Yes
create apps/qwerty/src/app/asdf/asdf-routing.module.ts (419 bytes)
create apps/qwerty/src/app/asdf/asdf.module.ts (377 bytes)
create apps/qwerty/src/app/asdf/asdf-container/asdf-container.component.html (33 bytes)
create apps/qwerty/src/app/asdf/asdf-container/asdf-container.component.spec.ts (678 bytes)
create apps/qwerty/src/app/asdf/asdf-container/asdf-container.component.ts (383 bytes)
create apps/qwerty/src/app/asdf/asdf-container/asdf-container.component.css (0 bytes)
update apps/qwerty/src/app/app-routing.module.ts (405 bytes)
update apps/qwerty/src/app/app.component.ts (440 bytes)

And now you’ve automated the creation of a lazily-loaded feature route with optional guards and nav-items!

Common Error(s)

Does not compute

I would frequently get this error when running the Nx schematics…

$ npm run workspace-schematic feature-route

> medium@0.0.0 workspace-schematic C:\dev\medium
> nx workspace-schematic "feature-route"

C:\dev\medium\node\_modules\yargs\yargs.js:1133
      else throw err
           ^

Error: ENOTEMPTY: directory not empty, rmdir 'C:\dev\medium\dist\out-tsc\tools\schematics'
    at Object.rmdirSync (fs.js:684:3)
    at rmkidsSync (C:\dev\medium\node\_modules\fs-extra\lib\remove\rimraf.js:304:27)
    at rmdirSync (C:\dev\medium\node\_modules\fs-extra\lib\remove\rimraf.js:281:7)
    at rimrafSync (C:\dev\medium\node\_modules\fs-extra\lib\remove\rimraf.js:252:7)
    at options.readdirSync.forEach.f (C:\dev\medium\node\_modules\fs-extra\lib\remove\rimraf.js:291:39)
    at Array.forEach (<anonymous>)
    at rmkidsSync (C:\dev\medium\node\_modules\fs-extra\lib\remove\rimraf.js:291:26)
    at rmdirSync (C:\dev\medium\node\_modules\fs-extra\lib\remove\rimraf.js:281:7)
    at Object.rimrafSync [as removeSync] (C:\dev\medium\node\_modules\fs-extra\lib\remove\rimraf.js:252:7)
    at compileTools (C:\dev\medium\node\_modules\[@nrwl](http://twitter.com/nrwl)\workspace\src\command-line\workspace-schematic.js:77:16)
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! medium@0.0.0 workspace-schematic: `nx workspace-schematic "feature-route"`
npm ERR! Exit status 1
npm ERR!
npm ERR! Failed at the medium@0.0.0 workspace-schematic script.
npm ERR! This is probably not a problem with npm. There is likely additional logging output above.

npm ERR! A complete log of this run can be found in:

Running rm -rf dist/out-tsc prior to running the schematic tends to do the trick. I believe the error is due to the virus scanner on my windows computer? ¯_(ツ)_/¯