Skip to content

Commit 527f54e

Browse files
mhartingtonimhoffd
authored andcommitted
feat(component): add custom component schematic (#68)
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 ```
1 parent 48495ec commit 527f54e

13 files changed

+344
-27
lines changed

collection.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
},
1313
"component": {
1414
"aliases": ["c"],
15-
"extends": "@schematics/angular:component"
15+
"factory": "./schematics/component",
16+
"description": "Create an Angular component.",
17+
"schema": "./schematics/component/schema.json"
1618
},
1719
"directive": {
1820
"aliases": ["d"],

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@
3737
"devDependencies": {
3838
"@angular-devkit/architect": "^0.12.3",
3939
"@angular-devkit/build-angular": "^0.12.3",
40-
"@angular-devkit/core": "^7.1.3",
41-
"@angular-devkit/schematics": "^7.1.3",
40+
"@angular-devkit/core": "~7.2.0",
41+
"@angular-devkit/schematics": "~7.2.0",
4242
"@semantic-release/changelog": "^3.0.0",
4343
"@semantic-release/git": "^7.0.4",
4444
"@semantic-release/github": "^5.0.6",
@@ -53,7 +53,8 @@
5353
"rimraf": "^2.6.2",
5454
"semantic-release": "^15.9.17",
5555
"tslint": "^5.12.0",
56-
"tslint-ionic-rules": "0.0.21"
56+
"tslint-ionic-rules": "0.0.21",
57+
"typescript-tslint-plugin": "0.3.1"
5758
},
5859
"peerDependencies": {
5960
"@angular-devkit/architect": ">=0.7.2",

schematics/component/files/__name@dasherize@if-flat__/__name@dasherize__.component.__styleext__

Whitespace-only changes.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<p>
2+
<%= dasherize(name) %> works!
3+
</p>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
2+
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
3+
4+
import { <%= classify(name) %>Page } from './<%= dasherize(name) %>.page';
5+
6+
describe('<%= classify(name) %>Page', () => {
7+
let component: <%= classify(name) %>Page;
8+
let fixture: ComponentFixture<<%= classify(name) %>Page>;
9+
10+
beforeEach(async(() => {
11+
TestBed.configureTestingModule({
12+
declarations: [ <%= classify(name) %>Page ],
13+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
14+
})
15+
.compileComponents();
16+
}));
17+
18+
beforeEach(() => {
19+
fixture = TestBed.createComponent(<%= classify(name) %>Page);
20+
component = fixture.componentInstance;
21+
fixture.detectChanges();
22+
});
23+
24+
it('should create', () => {
25+
expect(component).toBeTruthy();
26+
});
27+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Component, OnInit } from '@angular/core';
2+
3+
@Component({
4+
selector: '<%= selector %>',
5+
templateUrl: './<%= dasherize(name) %>.component.html',
6+
styleUrls: ['./<%= dasherize(name) %>.component.<%= styleext %>'],
7+
})
8+
export class <%= classify(name) %>Component implements OnInit {
9+
10+
constructor() { }
11+
12+
ngOnInit() {}
13+
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { NgModule } from '@angular/core';
2+
import { CommonModule } from '@angular/common';
3+
import { FormsModule } from '@angular/forms';
4+
5+
import { IonicModule } from '@ionic/angular';
6+
7+
import { <%= classify(name) %>Component } from './<%= dasherize(name) %>.component';
8+
9+
@NgModule({
10+
imports: [ CommonModule, FormsModule,IonicModule,],
11+
declarations: [<%= classify(name) %>Component],
12+
exports: [<%= classify(name) %>Component]
13+
})
14+
export class <%= classify(name) %>ComponentModule {}

schematics/component/index.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { strings } from '@angular-devkit/core';
2+
import { Rule, SchematicsException, Tree, apply, branchAndMerge, chain, filter, mergeWith, move, noop, template, url } from '@angular-devkit/schematics';
3+
import { addDeclarationToModule, addEntryComponentToModule, addExportToModule, addSymbolToNgModuleMetadata } from '@schematics/angular/utility/ast-utils';
4+
import { InsertChange } from '@schematics/angular/utility/change';
5+
import { buildRelativePath } from '@schematics/angular/utility/find-module';
6+
import { parseName } from '@schematics/angular/utility/parse-name';
7+
import { buildDefaultPath, getProject } from '@schematics/angular/utility/project';
8+
import { validateHtmlSelector, validateName } from '@schematics/angular/utility/validation';
9+
import * as ts from 'typescript';
10+
11+
import { buildSelector } from '../util';
12+
13+
import { Schema as ComponentOptions } from './schema';
14+
15+
function readIntoSourceFile(host: Tree, modulePath: string): ts.SourceFile {
16+
const text = host.read(modulePath);
17+
if (text === null) {
18+
throw new SchematicsException(`File ${modulePath} does not exist.`);
19+
}
20+
const sourceText = text.toString('utf-8');
21+
22+
return ts.createSourceFile(modulePath, sourceText, ts.ScriptTarget.Latest, true);
23+
}
24+
25+
function addImportToNgModule(options: ComponentOptions): Rule {
26+
return (host: Tree) => {
27+
if (!options.module) {
28+
return host;
29+
}
30+
if (!options.createModule && options.module) {
31+
addImportToDeclarations(host, options);
32+
}
33+
if (options.createModule && options.module) {
34+
addImportToImports(host, options);
35+
}
36+
return host;
37+
};
38+
}
39+
40+
function addImportToDeclarations(host: Tree, options: ComponentOptions): void {
41+
if (options.module) {
42+
const modulePath = options.module;
43+
let source = readIntoSourceFile(host, modulePath);
44+
45+
const componentPath = `/${options.path}/`
46+
+ (options.flat ? '' : strings.dasherize(options.name) + '/')
47+
+ strings.dasherize(options.name)
48+
+ '.component';
49+
const relativePath = buildRelativePath(modulePath, componentPath);
50+
const classifiedName = strings.classify(`${options.name}Component`);
51+
const declarationChanges = addDeclarationToModule(source,
52+
modulePath,
53+
classifiedName,
54+
relativePath);
55+
56+
const declarationRecorder = host.beginUpdate(modulePath);
57+
for (const change of declarationChanges) {
58+
if (change instanceof InsertChange) {
59+
declarationRecorder.insertLeft(change.pos, change.toAdd);
60+
}
61+
}
62+
host.commitUpdate(declarationRecorder);
63+
64+
if (options.export) {
65+
// Need to refresh the AST because we overwrote the file in the host.
66+
source = readIntoSourceFile(host, modulePath);
67+
68+
const exportRecorder = host.beginUpdate(modulePath);
69+
const exportChanges = addExportToModule(source, modulePath,
70+
strings.classify(`${options.name}Component`),
71+
relativePath);
72+
73+
for (const change of exportChanges) {
74+
if (change instanceof InsertChange) {
75+
exportRecorder.insertLeft(change.pos, change.toAdd);
76+
}
77+
}
78+
host.commitUpdate(exportRecorder);
79+
}
80+
81+
if (options.entryComponent) {
82+
// Need to refresh the AST because we overwrote the file in the host.
83+
source = readIntoSourceFile(host, modulePath);
84+
85+
const entryComponentRecorder = host.beginUpdate(modulePath);
86+
const entryComponentChanges = addEntryComponentToModule(
87+
source, modulePath,
88+
strings.classify(`${options.name}Component`),
89+
relativePath);
90+
91+
for (const change of entryComponentChanges) {
92+
if (change instanceof InsertChange) {
93+
entryComponentRecorder.insertLeft(change.pos, change.toAdd);
94+
}
95+
}
96+
host.commitUpdate(entryComponentRecorder);
97+
}
98+
}
99+
}
100+
101+
function addImportToImports(host: Tree, options: ComponentOptions): void {
102+
if (options.module) {
103+
const modulePath = options.module;
104+
const moduleSource = readIntoSourceFile(host, modulePath);
105+
106+
const componentModulePath = `/${options.path}/`
107+
+ (options.flat ? '' : strings.dasherize(options.name) + '/')
108+
+ strings.dasherize(options.name)
109+
+ '.module';
110+
111+
const relativePath = buildRelativePath(modulePath, componentModulePath);
112+
const classifiedName = strings.classify(`${options.name}ComponentModule`);
113+
const importChanges = addSymbolToNgModuleMetadata(moduleSource, modulePath, 'imports', classifiedName, relativePath);
114+
115+
const importRecorder = host.beginUpdate(modulePath);
116+
for (const change of importChanges) {
117+
if (change instanceof InsertChange) {
118+
importRecorder.insertLeft(change.pos, change.toAdd);
119+
}
120+
}
121+
host.commitUpdate(importRecorder);
122+
}
123+
}
124+
125+
export default function(options: ComponentOptions): Rule {
126+
return (host, context) => {
127+
if (!options.project) {
128+
throw new SchematicsException('Option (project) is required.');
129+
}
130+
131+
const project = getProject(host, options.project);
132+
133+
if (options.path === undefined) {
134+
options.path = buildDefaultPath(project);
135+
}
136+
137+
const parsedPath = parseName(options.path, options.name);
138+
options.name = parsedPath.name;
139+
options.path = parsedPath.path;
140+
options.selector = options.selector ? options.selector : buildSelector(options, project.prefix);
141+
142+
validateName(options.name);
143+
validateHtmlSelector(options.selector);
144+
145+
const templateSource = apply(url('./files'), [
146+
options.spec ? noop() : filter(p => !p.endsWith('.spec.ts')),
147+
options.createModule ? noop() : filter(p => !p.endsWith('.module.ts')),
148+
template({
149+
...strings,
150+
'if-flat': (s: string) => options.flat ? '' : s,
151+
...options,
152+
}),
153+
move(parsedPath.path),
154+
]);
155+
156+
return chain([
157+
branchAndMerge(chain([
158+
addImportToNgModule(options),
159+
mergeWith(templateSource),
160+
])),
161+
])(host, context);
162+
};
163+
}

schematics/component/schema.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export interface Schema {
2+
path?: string;
3+
project?: string;
4+
name: string;
5+
prefix?: string;
6+
styleext?: string;
7+
spec?: boolean;
8+
flat?: boolean;
9+
selector?: string;
10+
createModule?: boolean;
11+
module?: string;
12+
export?: boolean;
13+
entryComponent?: boolean;
14+
}

schematics/component/schema.json

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
{
2+
"$schema": "http://json-schema.org/schema",
3+
"id": "SchematicsIonicAngularComponent",
4+
"title": "@ionic/angular Component Options Schema",
5+
"type": "object",
6+
"properties": {
7+
"path": {
8+
"type": "string",
9+
"format": "path",
10+
"description": "The path to create the page",
11+
"visible": false
12+
},
13+
"project": {
14+
"type": "string",
15+
"description": "The name of the project",
16+
"$default": {
17+
"$source": "projectName"
18+
}
19+
},
20+
"name": {
21+
"type": "string",
22+
"description": "The name of the page",
23+
"$default": {
24+
"$source": "argv",
25+
"index": 0
26+
}
27+
},
28+
"prefix": {
29+
"type": "string",
30+
"description": "The prefix to apply to generated selectors",
31+
"alias": "p",
32+
"oneOf": [
33+
{
34+
"maxLength": 0
35+
},
36+
{
37+
"minLength": 1,
38+
"format": "html-selector"
39+
}
40+
]
41+
},
42+
"styleext": {
43+
"type": "string",
44+
"description": "The file extension of the style file for the page",
45+
"default": "css"
46+
},
47+
"spec": {
48+
"type": "boolean",
49+
"description": "Specifies if a spec file is generated",
50+
"default": true
51+
},
52+
"flat": {
53+
"type": "boolean",
54+
"description": "Flag to indicate if a dir is created",
55+
"default": false
56+
},
57+
"selector": {
58+
"type": "string",
59+
"format": "html-selector",
60+
"description": "The selector to use for the page"
61+
},
62+
"createModule": {
63+
"type": "boolean",
64+
"description": "Allows creating an NgModule for the component",
65+
"default": false
66+
},
67+
"module": {
68+
"type": "string",
69+
"description": "Allows adding to an NgModule's imports or declarations"
70+
},
71+
"export": {
72+
"type": "boolean",
73+
"default": false,
74+
"description": "When true, the declaring NgModule exports this component."
75+
},
76+
"entryComponent": {
77+
"type": "boolean",
78+
"default": false,
79+
"description": "When true, the new component is the entry component of the declaring NgModule."
80+
}
81+
},
82+
"required": []
83+
}

schematics/page/index.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { buildDefaultPath, getProject } from '@schematics/angular/utility/projec
88
import { validateHtmlSelector, validateName } from '@schematics/angular/utility/validation';
99
import * as ts from 'typescript';
1010

11+
import { buildSelector } from '../util';
12+
1113
import { Schema as PageOptions } from './schema';
1214

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

140-
function buildSelector(options: PageOptions, projectPrefix: string) {
141-
let selector = strings.dasherize(options.name);
142-
143-
if (options.prefix) {
144-
selector = `${options.prefix}-${selector}`;
145-
} else if (options.prefix === undefined && projectPrefix) {
146-
selector = `${projectPrefix}-${selector}`;
147-
}
148-
149-
return selector;
150-
}
151-
152142
export default function(options: PageOptions): Rule {
153143
return (host, context) => {
154144
if (!options.project) {

schematics/util/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { strings } from '@angular-devkit/core';
2+
3+
export function buildSelector(options: any, projectPrefix: string) {
4+
let selector = strings.dasherize(options.name);
5+
6+
if (options.prefix) {
7+
selector = `${options.prefix}-${selector}`;
8+
} else if (options.prefix === undefined && projectPrefix) {
9+
selector = `${projectPrefix}-${selector}`;
10+
}
11+
12+
return selector;
13+
}

0 commit comments

Comments
 (0)