loading...
This Dot

Schematics: Building Blocks

flakolefluk profile image Ignacio Le Fluk Updated on ・9 min read

This is part 2 of Schematics: Building blocks. Make sure to check part one if you haven't. We will continue with our previous work.

Chaining schematics

I'll use component generation, using the Angular CLI, as an example.
If you've used it before, you'll know that, when running the ng g c my-component, a number of operations will happen.

ng g component terminal output

We can see that two things are happening. First, a group of files is created, and then the module where it's located is updated.

These two operations could be split in two schematics.

  • Create files from templates
  • Update Module

Let's create a new schematic.

schematics blank component

We'll compose this schematic from two other schematics. Remember that a single file can contain more than a single factory function, and only the schematics added to collection.json will be available.

import { Rule, SchematicContext, Tree, chain } from '@angular-devkit/schematics';

export function component(options: any): Rule {
  return (tree: Tree, context: SchematicContext) => {
    return chain([
      createFiles(options),
      updateModule(options)
    ])(tree, context);
  };
}

export function createFiles(_options: any): Rule {
  return (tree: Tree, context: SchematicContext) => {
    context.logger.info('Will create files from templates');
    // create files implementation
    return tree;
}
}

export function updateModule(_options: any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    _context.logger.info('Will update module');
    // update module implementation
    return tree;
  };
}

I'm skipping some implementation details as we want to focus in the main function (component). The chain method imported from schematics will allow us to concatenate schematics. They will run in sequence one after the other.

If we build and run our schematic now (schematics .:component), we'll see the messages logged in the desired order.

noop

You may want to skip certain steps of this chain, based on some user input. You can easily add this functionality by importing the noop method also provided by the schematics package.

export function component(options: any): Rule {
  return (tree: Tree, context: SchematicContext) => {
    return chain([
      createFiles(options),
      options.skipModule ? noop() : updateModule(options)
    ])(tree, context);
  };
}

This way, you can chain multiple schematics, and pick the ones you need to run.

Importing schematics

You might be tempted to import, and extend other schematics of your collection the same way we chained our functions in the previous example.
Let's create a new schematic to see it in action.

schematics blank extended-schematic
import { Rule, SchematicContext, Tree, chain, schematic } from '@angular-devkit/schematics';
import { createFromTemplate } from '../create-from-template';

export function extendedSchematic(options: any): Rule {

  return (tree: Tree, context: SchematicContext) => {
    return chain([
      createFromTemplate(options),
        extend()
    ])(tree, context)
  };
}

export function extend(): Rule {
  return (tree: Tree, context: SchematicContext) => {
    context.logger.info('Extending schematic');
    return tree;
  };
}

If we build it, and test, but forget to add the folder argument, it will fail.
If you remember from our previous examples, a schematic might have a schema that defines a set of requirements, and adds extra information on fields, and how to request for that data(prompts). By importing that function, you'll be missing all of these settings. The appropriate way of importing an internal schematic is using the schematic method.

import { Rule, SchematicContext, Tree, chain, schematic } from '@angular-devkit/schematics';

export function extendedSchematic(options: any): Rule {

  return (tree: Tree, context: SchematicContext) => {
    return chain([
      schematic('create-from-template', {
      ...options
    }),
    extend()
  ])(tree, context)
  };
}

export function extend(): Rule {
  return (tree: Tree, context: SchematicContext) => {
    context.logger.info('Extending schematic');
    return tree;
  };
}

Now, if we run our schematic, you'll be prompted (if set) from the required arguments of the schematics that have been extended. Validation and parsing will also work as expected.

Extending external schematics

Extending our own schematics is a nice feature, but we might also need to extend schematics that do not belong to our collection. We know from our previous example that it would not be possible to add the collection and import the schematic that we would want to extend.
To solve this problem, we are required to use a similar function to the schematic function used before. This function is externalSchematic. Let's see it in action.

schematics blank extend-external-schematic
import {
  Rule,
  SchematicContext,
  Tree,
  chain,
  externalSchematic
} from "@angular-devkit/schematics";

export function external(options: any): Rule {
  return (tree: Tree, context: SchematicContext) => {
    return chain([
      externalSchematic("@schematics/angular", "component", {... options}),
      extend()
    ])(tree, context);
  };
}

export function extend(): Rule {
  return (tree: Tree, context: SchematicContext) => {
    context.logger.info("Extending schematic");
    return tree;
  };
}

We need to pass at least three parameters to the external schematic function: the name of the package that we will be using, the schematic name to run, and options.
If we build and run the schematic, we will get an error, because the package (@schematics/angular) is not installed, and because the collection is created to run inside an Angular project.

Tasks

When running our schematics, we may need to perform other operations without modifying our tree. For example, we may want to install our dependencies or run our linter. The @angular-devkit/schematics package comes with some of these tasks.
Let's create a new schematic.

schematic blank tasks
import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'

export function tasks(_options: any): Rule {
  return (tree: Tree, context: SchematicContext) => {
    context.addTask(new NodePackageInstallTask({ packageName: '@schematics/angular' }));
    return tree;
  };
}

We are adding a new task to our context (NodePackageInstallTask) that will effectively run the install command of our preferred package manager.
If a task depends on another task to be completed, addTask accepts an array of dependencies (other tasks ids) as a second argument.

import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';
import { NodePackageInstallTask, TslintFixTask } from '@angular-devkit/schematics/tasks'

export function tasks(_options: any): Rule {
  return (tree: Tree, context: SchematicContext) => {
    const taskId = context.addTask(new NodePackageInstallTask({ packageName: '@schematics/angular' }));
    context.addTask(new TslintFixTask({}), [taskId])
    return tree;
  };
}

In this example, TsLintFixTask will not run until
NodePackageInstallTask has finished because it's listed as a dependency.

To run tasks schematic using globally installed schematics-cli make sure you have tslint and typescript installed globally otherwise TsLintFixTask will fail.

Tests

So far, we've accomplished a lot of different operations in the file system, and we've extended our schematics, and external schematics. However, we're missing an important part of our schematics collection to be ready. Testing. How do we test schematics?
Let's start with the first of our schematics, create-file and the auto-generated test file.

import { Tree } from '@angular-devkit/schematics';
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
import * as path from 'path';

const collectionPath = path.join(__dirname, '../collection.json');

describe('create-file', () => {
  it('works', () => {
    const runner = new SchematicTestRunner('schematics', collectionPath);
    const tree = runner.runSchematic('create-file', {}, Tree.empty());

    expect(tree.files).toEqual([]);
  });
});

We created a test runner, and gave it the path to our collection schema. Then we ran our schematic on a given tree. In this example, an empty tree.
If we run this test as it is - it will fail.

Failed test

Remember, we added a required path argument in our schema when we created it. Now that we now that the test fails, let's write a test that checks if it fails, and also another one for when it succeeds.

// create-file/index.spec.ts
import { Tree } from '@angular-devkit/schematics';
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
import * as path from 'path';

const collectionPath = path.join(__dirname, '../collection.json');

describe('create-file', () => {
  it('Should throw if path argument is missing', () => {
    const runner = new SchematicTestRunner('schematics', collectionPath);
    let errorMessage;
    try {
      runner.runSchematic('create-file', {}, Tree.empty());
    } catch (e) {
      errorMessage = e.message;
    }
    expect(errorMessage).toMatch(/required property 'path'/);
  });

  it('Should create a file in the given path', () => {
    const runner = new SchematicTestRunner('schematics', collectionPath);
    const tree = runner.runSchematic('create-file', { path: 'my-file.ts' }, Tree.empty());
    expect(tree.files).toEqual(['/my-file.ts']);
  });
});

Test all possible errors. When modifying a file, test its contents.

// ts-ast/index.spec.ts
import { Tree } from '@angular-devkit/schematics';
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
import * as path from 'path';

const collectionPath = path.join(__dirname, '../collection.json');

describe('ts-ast', () => {
  it('Should throw if path argument is missing', () => {
    const runner = new SchematicTestRunner('schematics', collectionPath);
    let errorMessage;
    try {
      runner.runSchematic('ts-ast', {}, Tree.empty());
    } catch (e) {
      errorMessage = e.message;
    }
    expect(errorMessage).toMatch(/required property 'path'/);
  });

  it("Should throw if file in the given path does not exist", () => {
    const runner = new SchematicTestRunner("schematics", collectionPath);
    let errorMessage;
    try {
      runner.runSchematic("ts-ast", { path: "my-file.ts" }, Tree.empty());
    } catch (e) {
      errorMessage = e.message;
    }
    expect(errorMessage).toMatch(/File my-file.ts not found/);
  });

  it("Should throw if no interface is present", () => {
    const runner = new SchematicTestRunner("schematics", collectionPath);
    const sourceTree = Tree.empty();
    sourceTree.create('test.ts', 
      `export class MyClass { }`
    );
    let errorMessage;
    try {
      runner.runSchematic('ts-ast', { path: 'test.ts' }, sourceTree);
    } catch (e) {
      errorMessage = e.message;
    }
    expect(errorMessage).toMatch(/No Interface found/);
  });

  it('Should update a file in the given path', () => {
    const runner = new SchematicTestRunner('schematics', collectionPath);
    const sourceTree = Tree.empty();
    sourceTree.create('test.ts', 
      `export interface MyInterface {
        name: string;
      }`
    );
    const tree = runner.runSchematic('ts-ast', { path: 'test.ts' }, sourceTree);
    expect(tree.files).toEqual(['/test.ts']);
    expect(tree.readContent('/test.ts')).toEqual(
      `export interface MyInterface {
        first: string;
        name: string;
        last: string;
      }`
     );
  });
});

You can find all the tests in the repository

Schematics and the Angular CLI

So far, we've used schematics without the Angular CLI. Schematics can have any name, but there are a few ones that have a special meaning when used with the ng command.
For example, running ng add <package_name> will download the package, will check for a collection reference in the schematics key inside package.json, and will run the ng-add schematic of that collection.

Let's create a new schematic.

schematics blank ng-add

This is the first time we will have to think about how our schematic will have to interact with an angular workspace. We must take into account what's required to run it.
In this example, we'll make a simple modification to the workspace README.md file

Let's take a look at the implementation.

import { Rule, SchematicContext, Tree } from '@angular-devkit/schematics';

export function ngAdd(_options:any): Rule {
  return (tree: Tree, _context: SchematicContext) => {
    return tree.overwrite('README.md', 'overwritten file');
  };
}

This looks very simple, but when testing it, we think that this should run inside an angular workspace. This is a simple example, but when modifying projects, this will become more evident.
We could create this new angular workspace manually, but there's a better approach. We'll use the @schematics/angular package to create a workspace, just like the Angular CLI does.
Let's install the package first.

npm install --save-dev @schematics/angular
import { SchematicTestRunner } from '@angular-devkit/schematics/testing';
import * as path from 'path';
import { Tree } from '@angular-devkit/schematics';

const collectionPath = path.join(__dirname, '../collection.json');

describe('ng-add', () => {
  const workspaceOptions = {
    name: 'workspace',
    newProjectRoot: 'projects',
    version: '8.0.0',
  };

  const runner = new SchematicTestRunner('schematics', collectionPath);

  it('should throw if no readme is not found', async () => {
    let errorMessage;
    try{
      runner.runSchematic('ng-add', { }, Tree.empty());
    } catch(e){
      errorMessage = e.message;
    }
    expect(errorMessage).toMatch(/Path "\/README.md" does not exist./);

  });

  it('overwrite workspace README file', async () => {
    const sourceTree = await runner.runExternalSchematicAsync('@schematics/angular','workspace', workspaceOptions).toPromise();
    const tree = runner.runSchematic('ng-add', {}, sourceTree);
    expect(tree.files).toContain('/README.md');
    expect(tree.readContent('/README.md')).toMatch(/overwritten file/);
  });
});

The second test is running an external schematic for the installed package to create a workspace. Then, we run our ng-add schematic to modify the tree that contains an angular workspace. There are more things that you can do with the @schematics/angular package to prepare your tree to test, like creating new projects or components. It's a great way to mimic a real project.
Our previous schematics were very generic, if we wanted to run them inside of an angular project, we would have to recreate the environment where we expect them to be used when testing.

Final words

  • You can find the code here
  • Split your schematics into simpler ones if possible. You may need to reuse them somewhere else and they can always be chained.
  • Always test your schematics and recreate the environment where they will run the best if you can. If they will run on an angular workspace, create it. If there are other schematics available to do that task, use them. That's one of the features of schematics: to avoid repetitive tasks.
  • Always use the schematic and externalShematic functions when importing them from somewhere else.
  • In part 3, we will create a schematic to add TailwindCSS to an Angular project.

References

Related blog posts

This article was written by Ignacio Falk who is a software engineer at This Dot.

You can follow him on Twitter at @flakolefluk .

Need JavaScript consulting, mentoring, or training help? Check out our list of services at This Dot Labs.

Discussion

pic
Editor guide
Collapse
ericjeker profile image
Eric Jeker

Hi Ignacio,

Thanks for this tutorial. Is there an API reference for the Angular schematics? Or do we have to just dig their code?