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.
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 installedschematics-cli
make sure you havetslint
andtypescript
installed globally otherwiseTsLintFixTask
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.
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
andexternalShematic
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.
Top comments (1)
Hi Ignacio,
Thanks for this tutorial. Is there an API reference for the Angular schematics? Or do we have to just dig their code?