Skip to content

Commit

Permalink
feat(component): add custom component schematic (#68)
Browse files Browse the repository at this point in the history
This feature better aligns lazy-loaded page structure (a default of Ionic 4) with generated components.

Previously, generating a component would register the component with the root module, which would make it unavailable in all pages that were lazy-loaded. 

The new component schematic offers a new option: `--createModule`. It can be combined with other options such as `--module` and `--export` to handle several use cases.

* test1: Generates a component and does not register it.
* test2: Generates a component with an NgModule, but does not register it.
* test3: Generates a component with an NgModule and registers the module with tab1's NgModule.
* test4: Generates a component and registers the component with tab1's NgModule.
* test5: Generates a component, registers the component with a component NgModule, and exports it from that NgModule.

```
ng g component test1
ng g component test2 --createModule
ng g component test3 --createModule --module /src/app/tab1/tab1.module.ts
ng g component test4 --entryComponent --module /src/app/tab1/tab1.module.ts
ng g component test5 --export --module /src/app/components.module.ts
```
  • Loading branch information
mhartington authored and imhoffd committed Feb 6, 2019
1 parent 48495ec commit 527f54e
Show file tree
Hide file tree
Showing 13 changed files with 344 additions and 27 deletions.
4 changes: 3 additions & 1 deletion collection.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
},
"component": {
"aliases": ["c"],
"extends": "@schematics/angular:component"
"factory": "./schematics/component",
"description": "Create an Angular component.",
"schema": "./schematics/component/schema.json"
},
"directive": {
"aliases": ["d"],
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@
"devDependencies": {
"@angular-devkit/architect": "^0.12.3",
"@angular-devkit/build-angular": "^0.12.3",
"@angular-devkit/core": "^7.1.3",
"@angular-devkit/schematics": "^7.1.3",
"@angular-devkit/core": "~7.2.0",
"@angular-devkit/schematics": "~7.2.0",
"@semantic-release/changelog": "^3.0.0",
"@semantic-release/git": "^7.0.4",
"@semantic-release/github": "^5.0.6",
Expand All @@ -53,7 +53,8 @@
"rimraf": "^2.6.2",
"semantic-release": "^15.9.17",
"tslint": "^5.12.0",
"tslint-ionic-rules": "0.0.21"
"tslint-ionic-rules": "0.0.21",
"typescript-tslint-plugin": "0.3.1"
},
"peerDependencies": {
"@angular-devkit/architect": ">=0.7.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<p>
<%= dasherize(name) %> works!
</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import { <%= classify(name) %>Page } from './<%= dasherize(name) %>.page';

describe('<%= classify(name) %>Page', () => {
let component: <%= classify(name) %>Page;
let fixture: ComponentFixture<<%= classify(name) %>Page>;

beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ <%= classify(name) %>Page ],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
.compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(<%= classify(name) %>Page);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Component, OnInit } from '@angular/core';

@Component({
selector: '<%= selector %>',
templateUrl: './<%= dasherize(name) %>.component.html',
styleUrls: ['./<%= dasherize(name) %>.component.<%= styleext %>'],
})
export class <%= classify(name) %>Component implements OnInit {

constructor() { }

ngOnInit() {}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';

import { IonicModule } from '@ionic/angular';

import { <%= classify(name) %>Component } from './<%= dasherize(name) %>.component';

@NgModule({
imports: [ CommonModule, FormsModule,IonicModule,],
declarations: [<%= classify(name) %>Component],
exports: [<%= classify(name) %>Component]
})
export class <%= classify(name) %>ComponentModule {}
163 changes: 163 additions & 0 deletions schematics/component/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { strings } from '@angular-devkit/core';
import { Rule, SchematicsException, Tree, apply, branchAndMerge, chain, filter, mergeWith, move, noop, template, url } from '@angular-devkit/schematics';
import { addDeclarationToModule, addEntryComponentToModule, addExportToModule, addSymbolToNgModuleMetadata } from '@schematics/angular/utility/ast-utils';
import { InsertChange } from '@schematics/angular/utility/change';
import { buildRelativePath } from '@schematics/angular/utility/find-module';
import { parseName } from '@schematics/angular/utility/parse-name';
import { buildDefaultPath, getProject } from '@schematics/angular/utility/project';
import { validateHtmlSelector, validateName } from '@schematics/angular/utility/validation';
import * as ts from 'typescript';

import { buildSelector } from '../util';

import { Schema as ComponentOptions } from './schema';

function readIntoSourceFile(host: Tree, modulePath: string): ts.SourceFile {
const text = host.read(modulePath);
if (text === null) {
throw new SchematicsException(`File ${modulePath} does not exist.`);
}
const sourceText = text.toString('utf-8');

return ts.createSourceFile(modulePath, sourceText, ts.ScriptTarget.Latest, true);
}

function addImportToNgModule(options: ComponentOptions): Rule {
return (host: Tree) => {
if (!options.module) {
return host;
}
if (!options.createModule && options.module) {
addImportToDeclarations(host, options);
}
if (options.createModule && options.module) {
addImportToImports(host, options);
}
return host;
};
}

function addImportToDeclarations(host: Tree, options: ComponentOptions): void {
if (options.module) {
const modulePath = options.module;
let source = readIntoSourceFile(host, modulePath);

const componentPath = `/${options.path}/`
+ (options.flat ? '' : strings.dasherize(options.name) + '/')
+ strings.dasherize(options.name)
+ '.component';
const relativePath = buildRelativePath(modulePath, componentPath);
const classifiedName = strings.classify(`${options.name}Component`);
const declarationChanges = addDeclarationToModule(source,
modulePath,
classifiedName,
relativePath);

const declarationRecorder = host.beginUpdate(modulePath);
for (const change of declarationChanges) {
if (change instanceof InsertChange) {
declarationRecorder.insertLeft(change.pos, change.toAdd);
}
}
host.commitUpdate(declarationRecorder);

if (options.export) {
// Need to refresh the AST because we overwrote the file in the host.
source = readIntoSourceFile(host, modulePath);

const exportRecorder = host.beginUpdate(modulePath);
const exportChanges = addExportToModule(source, modulePath,
strings.classify(`${options.name}Component`),
relativePath);

for (const change of exportChanges) {
if (change instanceof InsertChange) {
exportRecorder.insertLeft(change.pos, change.toAdd);
}
}
host.commitUpdate(exportRecorder);
}

if (options.entryComponent) {
// Need to refresh the AST because we overwrote the file in the host.
source = readIntoSourceFile(host, modulePath);

const entryComponentRecorder = host.beginUpdate(modulePath);
const entryComponentChanges = addEntryComponentToModule(
source, modulePath,
strings.classify(`${options.name}Component`),
relativePath);

for (const change of entryComponentChanges) {
if (change instanceof InsertChange) {
entryComponentRecorder.insertLeft(change.pos, change.toAdd);
}
}
host.commitUpdate(entryComponentRecorder);
}
}
}

function addImportToImports(host: Tree, options: ComponentOptions): void {
if (options.module) {
const modulePath = options.module;
const moduleSource = readIntoSourceFile(host, modulePath);

const componentModulePath = `/${options.path}/`
+ (options.flat ? '' : strings.dasherize(options.name) + '/')
+ strings.dasherize(options.name)
+ '.module';

const relativePath = buildRelativePath(modulePath, componentModulePath);
const classifiedName = strings.classify(`${options.name}ComponentModule`);
const importChanges = addSymbolToNgModuleMetadata(moduleSource, modulePath, 'imports', classifiedName, relativePath);

const importRecorder = host.beginUpdate(modulePath);
for (const change of importChanges) {
if (change instanceof InsertChange) {
importRecorder.insertLeft(change.pos, change.toAdd);
}
}
host.commitUpdate(importRecorder);
}
}

export default function(options: ComponentOptions): Rule {
return (host, context) => {
if (!options.project) {
throw new SchematicsException('Option (project) is required.');
}

const project = getProject(host, options.project);

if (options.path === undefined) {
options.path = buildDefaultPath(project);
}

const parsedPath = parseName(options.path, options.name);
options.name = parsedPath.name;
options.path = parsedPath.path;
options.selector = options.selector ? options.selector : buildSelector(options, project.prefix);

validateName(options.name);
validateHtmlSelector(options.selector);

const templateSource = apply(url('./files'), [
options.spec ? noop() : filter(p => !p.endsWith('.spec.ts')),
options.createModule ? noop() : filter(p => !p.endsWith('.module.ts')),
template({
...strings,
'if-flat': (s: string) => options.flat ? '' : s,
...options,
}),
move(parsedPath.path),
]);

return chain([
branchAndMerge(chain([
addImportToNgModule(options),
mergeWith(templateSource),
])),
])(host, context);
};
}
14 changes: 14 additions & 0 deletions schematics/component/schema.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export interface Schema {
path?: string;
project?: string;
name: string;
prefix?: string;
styleext?: string;
spec?: boolean;
flat?: boolean;
selector?: string;
createModule?: boolean;
module?: string;
export?: boolean;
entryComponent?: boolean;
}
83 changes: 83 additions & 0 deletions schematics/component/schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
{
"$schema": "http://json-schema.org/schema",
"id": "SchematicsIonicAngularComponent",
"title": "@ionic/angular Component Options Schema",
"type": "object",
"properties": {
"path": {
"type": "string",
"format": "path",
"description": "The path to create the page",
"visible": false
},
"project": {
"type": "string",
"description": "The name of the project",
"$default": {
"$source": "projectName"
}
},
"name": {
"type": "string",
"description": "The name of the page",
"$default": {
"$source": "argv",
"index": 0
}
},
"prefix": {
"type": "string",
"description": "The prefix to apply to generated selectors",
"alias": "p",
"oneOf": [
{
"maxLength": 0
},
{
"minLength": 1,
"format": "html-selector"
}
]
},
"styleext": {
"type": "string",
"description": "The file extension of the style file for the page",
"default": "css"
},
"spec": {
"type": "boolean",
"description": "Specifies if a spec file is generated",
"default": true
},
"flat": {
"type": "boolean",
"description": "Flag to indicate if a dir is created",
"default": false
},
"selector": {
"type": "string",
"format": "html-selector",
"description": "The selector to use for the page"
},
"createModule": {
"type": "boolean",
"description": "Allows creating an NgModule for the component",
"default": false
},
"module": {
"type": "string",
"description": "Allows adding to an NgModule's imports or declarations"
},
"export": {
"type": "boolean",
"default": false,
"description": "When true, the declaring NgModule exports this component."
},
"entryComponent": {
"type": "boolean",
"default": false,
"description": "When true, the new component is the entry component of the declaring NgModule."
}
},
"required": []
}
14 changes: 2 additions & 12 deletions schematics/page/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { buildDefaultPath, getProject } from '@schematics/angular/utility/projec
import { validateHtmlSelector, validateName } from '@schematics/angular/utility/validation';
import * as ts from 'typescript';

import { buildSelector } from '../util';

import { Schema as PageOptions } from './schema';

function findRoutingModuleFromOptions(host: Tree, options: ModuleOptions): Path | undefined {
Expand Down Expand Up @@ -137,18 +139,6 @@ function addRouteToRoutesArray(source: ts.SourceFile, ngModulePath: string, rout
return [];
}

function buildSelector(options: PageOptions, projectPrefix: string) {
let selector = strings.dasherize(options.name);

if (options.prefix) {
selector = `${options.prefix}-${selector}`;
} else if (options.prefix === undefined && projectPrefix) {
selector = `${projectPrefix}-${selector}`;
}

return selector;
}

export default function(options: PageOptions): Rule {
return (host, context) => {
if (!options.project) {
Expand Down
13 changes: 13 additions & 0 deletions schematics/util/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { strings } from '@angular-devkit/core';

export function buildSelector(options: any, projectPrefix: string) {
let selector = strings.dasherize(options.name);

if (options.prefix) {
selector = `${options.prefix}-${selector}`;
} else if (options.prefix === undefined && projectPrefix) {
selector = `${projectPrefix}-${selector}`;
}

return selector;
}
Loading

0 comments on commit 527f54e

Please sign in to comment.