diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..9b95bd991f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +/dist +/modules/schematics/src/*/files/* \ No newline at end of file diff --git a/build/builder.ts b/build/builder.ts index 8736d44dc2..faa6bbcf02 100644 --- a/build/builder.ts +++ b/build/builder.ts @@ -12,6 +12,7 @@ export default createBuilder([ ['Cleaning JavaScript files', tasks.cleanJavaScriptFiles], ['Removing remaining sourcemap files', tasks.removeRemainingSourceMapFiles], ['Copying type definition files', tasks.copyTypeDefinitionFiles], + ['Copying schematic files', tasks.copySchematicFiles], ['Minifying UMD bundles', tasks.minifyUmdBundles], ['Copying documents', tasks.copyDocs], ['Copying package.json files', tasks.copyPackageJsonFiles], diff --git a/build/config.ts b/build/config.ts index 18e1c06a6f..6149341fd8 100644 --- a/build/config.ts +++ b/build/config.ts @@ -1,6 +1,7 @@ export interface PackageDescription { name: string; hasTestingModule: boolean; + bundle: boolean; } export interface Config { @@ -12,25 +13,36 @@ export const packages: PackageDescription[] = [ { name: 'store', hasTestingModule: false, + bundle: true, }, { name: 'effects', hasTestingModule: true, + bundle: true, }, { name: 'router-store', hasTestingModule: false, + bundle: true, }, { name: 'store-devtools', hasTestingModule: false, + bundle: true, }, { name: 'entity', hasTestingModule: false, + bundle: true, }, { name: 'codegen', hasTestingModule: false, + bundle: true, + }, + { + name: 'schematics', + hasTestingModule: false, + bundle: false, }, ]; diff --git a/build/tasks.ts b/build/tasks.ts index 55a2cde70b..7bb2f53541 100644 --- a/build/tasks.ts +++ b/build/tasks.ts @@ -41,7 +41,9 @@ async function _compilePackagesWithNgc(pkg: string) { : [pkg, 'index']; const entryTypeDefinition = `export * from './${exportPath}/${moduleName}';`; - const entryMetadata = `{"__symbolic":"module","version":3,"metadata":{},"exports":[{"from":"./${pkg}/index"}]}`; + const entryMetadata = `{"__symbolic":"module","version":3,"metadata":{},"exports":[{"from":"./${ + pkg + }/index"}]}`; await Promise.all([ util.writeFile(`./dist/packages/${pkg}.d.ts`, entryTypeDefinition), @@ -57,6 +59,9 @@ export async function bundleFesms(config: Config) { const pkgs = util.getAllPackages(config); await mapAsync(pkgs, async pkg => { + if (!util.shouldBundle(config, pkg)) { + return; + } const topLevelName = util.getTopLevelName(pkg); await util.exec('rollup', [ @@ -79,6 +84,9 @@ export async function downLevelFesmsToES5(config: Config) { const tscArgs = ['--target es5', '--module es2015', '--noLib', '--sourceMap']; await mapAsync(packages, async pkg => { + if (!util.shouldBundle(config, pkg)) { + return; + } const topLevelName = util.getTopLevelName(pkg); const file = `./dist/${topLevelName}/${config.scope}/${pkg}.js`; @@ -98,6 +106,9 @@ export async function downLevelFesmsToES5(config: Config) { */ export async function createUmdBundles(config: Config) { await mapAsync(util.getAllPackages(config), async pkg => { + if (!util.shouldBundle(config, pkg)) { + return; + } const topLevelName = util.getTopLevelName(pkg); const destinationName = util.getDestinationName(pkg); @@ -127,6 +138,9 @@ export async function cleanTypeScriptFiles(config: Config) { * leaving the bundles and FESM in place */ export async function cleanJavaScriptFiles(config: Config) { + const packages = util + .getTopLevelPackages(config) + .filter(pkg => !util.shouldBundle(config, pkg)); const jsFilesGlob = './dist/packages/**/*.js'; const jsExcludeFilesFlob = './dist/packages/(bundles|@ngrx)/**/*.js'; const filesToRemove = await util.getListOfFiles( @@ -134,7 +148,11 @@ export async function cleanJavaScriptFiles(config: Config) { jsExcludeFilesFlob ); - await mapAsync(filesToRemove, util.remove); + const filteredFilesToRemove = filesToRemove.filter((file: string) => + packages.some(pkg => file.indexOf(pkg) === -1) + ); + + await mapAsync(filteredFilesToRemove, util.remove); } /** @@ -143,6 +161,9 @@ export async function cleanJavaScriptFiles(config: Config) { */ export async function renamePackageEntryFiles(config: Config) { await mapAsync(util.getAllPackages(config), async pkg => { + if (!util.shouldBundle(config, pkg)) { + return; + } const bottomLevelName = util.getBottomLevelName(pkg); const files = await util.getListOfFiles(`./dist/packages/${pkg}/index.**`); @@ -192,6 +213,9 @@ export async function minifyUmdBundles(config: Config) { const uglifyArgs = ['-c', '-m', '--comments']; await mapAsync(util.getAllPackages(config), async pkg => { + if (!util.shouldBundle(config, pkg)) { + return; + } const topLevelName = util.getTopLevelName(pkg); const destinationName = util.getDestinationName(pkg); const file = `./dist/${topLevelName}/bundles/${destinationName}.umd.js`; @@ -201,7 +225,9 @@ export async function minifyUmdBundles(config: Config) { file, ...uglifyArgs, `-o ${out}`, - `--source-map "filename='${out}.map' includeSources='${file}', content='${file}.map'"`, + `--source-map "filename='${out}.map' includeSources='${file}', content='${ + file + }.map'"`, ]); }); } @@ -288,3 +314,28 @@ export function mapAsync( ) { return Promise.all(list.map(mapFn)); } + +/** + * Copy schematics files + */ +export async function copySchematicFiles(config: Config) { + const packages = util + .getTopLevelPackages(config) + .filter(pkg => !util.shouldBundle(config, pkg)); + + const collectionFiles = await util.getListOfFiles( + `./modules/?(${packages.join('|')})/collection.json` + ); + const schemaFiles = await util.getListOfFiles( + `./modules/?(${packages.join('|')})/src/*/schema.*` + ); + const templateFiles = await util.getListOfFiles( + `./modules/?(${packages.join('|')})/src/*/files/*` + ); + const files = [...collectionFiles, ...schemaFiles, ...templateFiles]; + + await mapAsync(files, async file => { + const target = file.replace('modules/', 'dist/'); + await util.copy(file, target); + }); +} diff --git a/build/util.ts b/build/util.ts index fe73f07106..2f49795dcc 100644 --- a/build/util.ts +++ b/build/util.ts @@ -134,9 +134,12 @@ export function createBuilder(tasks: TaskDef[]) { } export function flatMap(list: K[], mapFn: (item: K) => J[]): J[] { - return list.reduce(function(newList, nextItem) { - return [...newList, ...mapFn(nextItem)]; - }, [] as J[]); + return list.reduce( + function(newList, nextItem) { + return [...newList, ...mapFn(nextItem)]; + }, + [] as J[] + ); } export function getTopLevelPackages(config: Config) { @@ -178,3 +181,9 @@ export function getBottomLevelName(packageName: string) { export function baseDir(...dirs: string[]): string { return `"${path.resolve(__dirname, '../', ...dirs)}"`; } + +export function shouldBundle(config: Config, packageName: string) { + const pkg = config.packages.find(pkg => pkg.name === packageName); + + return pkg ? pkg.bundle : false; +} diff --git a/circle.yml b/circle.yml index 4c3bd85969..2901b40101 100644 --- a/circle.yml +++ b/circle.yml @@ -2,7 +2,7 @@ machine: environment: PATH: "${PATH}:${HOME}/${CIRCLE_PROJECT_REPONAME}/node_modules/.bin" node: - version: 8.6.0 + version: 8.9.1 dependencies: pre: diff --git a/example-app/app/auth/components/login-form.component.spec.ts b/example-app/app/auth/components/login-form.component.spec.ts index 99e943cb84..97a479d061 100644 --- a/example-app/app/auth/components/login-form.component.spec.ts +++ b/example-app/app/auth/components/login-form.component.spec.ts @@ -33,7 +33,7 @@ describe('Login Page', () => { * HTML. * * We can also use this as a validation tool against changes - * to the component's template against the currently stored + * to the component's template against the currently stored * snapshot. */ expect(fixture).toMatchSnapshot(); diff --git a/example-app/app/books/effects/book.ts b/example-app/app/books/effects/book.ts index 9ea15d7af2..b112d46ce5 100644 --- a/example-app/app/books/effects/book.ts +++ b/example-app/app/books/effects/book.ts @@ -61,10 +61,10 @@ export class BookEffects { @Inject(SEARCH_DEBOUNCE) private debounce: number, /** - * You inject an optional Scheduler that will be undefined - * in normal application usage, but its injected here so that you can mock out - * during testing using the RxJS TestScheduler for simulating passages of time. - */ + * You inject an optional Scheduler that will be undefined + * in normal application usage, but its injected here so that you can mock out + * during testing using the RxJS TestScheduler for simulating passages of time. + */ @Optional() @Inject(SEARCH_SCHEDULER) private scheduler: Scheduler diff --git a/example-app/app/books/reducers/index.ts b/example-app/app/books/reducers/index.ts index c0bb0839bf..299dd2c37c 100644 --- a/example-app/app/books/reducers/index.ts +++ b/example-app/app/books/reducers/index.ts @@ -11,7 +11,7 @@ export interface BooksState { } export interface State extends fromRoot.State { - 'books': BooksState; + books: BooksState; } export const reducers = { @@ -39,7 +39,7 @@ export const reducers = { /** * The createFeatureSelector function selects a piece of state from the root of the state object. * This is used for selecting feature states that are loaded eagerly or lazily. -*/ + */ export const getBooksState = createFeatureSelector('books'); /** diff --git a/modules/codegen/src/metadata/get-type.ts b/modules/codegen/src/metadata/get-type.ts index 18b10a9311..ee9852fac4 100644 --- a/modules/codegen/src/metadata/get-type.ts +++ b/modules/codegen/src/metadata/get-type.ts @@ -13,7 +13,7 @@ export function getType( } return ts.isLiteralTypeNode(typeProperty.type as any) - ? typeProperty.type as any + ? (typeProperty.type as any) : undefined; // return !!typeProperty && ts.isLiteralTypeNode(typeProperty.type) ? typeProperty.type : undefined; diff --git a/modules/codegen/src/metadata/is-action-descendent.ts b/modules/codegen/src/metadata/is-action-descendent.ts index 392789370d..64734e4083 100644 --- a/modules/codegen/src/metadata/is-action-descendent.ts +++ b/modules/codegen/src/metadata/is-action-descendent.ts @@ -9,9 +9,9 @@ export function isActionDescendent( return heritageClauses.some(clause => { /** * TODO: This breaks if the interface looks like this: - * + * * interface MyAction extends ngrx.Action { } - * + * */ return clause.types.some(type => type.expression.getText() === 'Action'); }); diff --git a/modules/effects/spec/actions.spec.ts b/modules/effects/spec/actions.spec.ts index d1da8e5332..37250d3d96 100644 --- a/modules/effects/spec/actions.spec.ts +++ b/modules/effects/spec/actions.spec.ts @@ -63,11 +63,15 @@ describe('Actions', function() { const actions = [ADD, ADD, SUBTRACT, ADD, SUBTRACT]; const expected = actions.filter(type => type === ADD); - actions$.ofType(ADD).map(update => update.type).toArray().subscribe({ - next(actual) { - expect(actual).toEqual(expected); - }, - }); + actions$ + .ofType(ADD) + .map(update => update.type) + .toArray() + .subscribe({ + next(actual) { + expect(actual).toEqual(expected); + }, + }); actions.forEach(action => dispatcher.next({ type: action })); dispatcher.complete(); diff --git a/modules/effects/src/effects_resolver.ts b/modules/effects/src/effects_resolver.ts index bb8956a33a..e23e3a45d6 100644 --- a/modules/effects/src/effects_resolver.ts +++ b/modules/effects/src/effects_resolver.ts @@ -14,31 +14,31 @@ export function mergeEffects( ): Observable { const sourceName = getSourceForInstance(sourceInstance).constructor.name; - const observables: Observable[] = getSourceMetadata( - sourceInstance - ).map(({ propertyName, dispatch }): Observable => { - const observable: Observable = - typeof sourceInstance[propertyName] === 'function' - ? sourceInstance[propertyName]() - : sourceInstance[propertyName]; - - if (dispatch === false) { - return ignoreElements.call(observable); + const observables: Observable[] = getSourceMetadata(sourceInstance).map( + ({ propertyName, dispatch }): Observable => { + const observable: Observable = + typeof sourceInstance[propertyName] === 'function' + ? sourceInstance[propertyName]() + : sourceInstance[propertyName]; + + if (dispatch === false) { + return ignoreElements.call(observable); + } + + const materialized$ = materialize.call(observable); + + return map.call( + materialized$, + (notification: Notification): EffectNotification => ({ + effect: sourceInstance[propertyName], + notification, + propertyName, + sourceName, + sourceInstance, + }) + ); } - - const materialized$ = materialize.call(observable); - - return map.call( - materialized$, - (notification: Notification): EffectNotification => ({ - effect: sourceInstance[propertyName], - notification, - propertyName, - sourceName, - sourceInstance, - }) - ); - }); + ); return merge(...observables); } diff --git a/modules/schematics/README.md b/modules/schematics/README.md new file mode 100644 index 0000000000..7bbb0361fd --- /dev/null +++ b/modules/schematics/README.md @@ -0,0 +1,6 @@ +@ngrx/schematics +======= + +The sources for this package are in the main [ngrx/platform](https://github.com/ngrx/platform) repo. Please file issues and pull requests against that repo. + +License: MIT diff --git a/modules/schematics/collection.json b/modules/schematics/collection.json new file mode 100644 index 0000000000..e44997e058 --- /dev/null +++ b/modules/schematics/collection.json @@ -0,0 +1,93 @@ +{ + "schematics": { + "action": { + "factory": "./src/action", + "schema": "./src/action/schema.json", + "description": "Add store actions" + }, + + "class": { + "aliases": [ "cl" ], + "extends": "@schematics/angular:class" + }, + + "component": { + "aliases": [ "c" ], + "extends": "@schematics/angular:component" + }, + + "container": { + "factory": "./src/container", + "schema": "./src/container/schema.json", + "description": "Add store container component" + }, + + "directive": { + "aliases": [ "d" ], + "extends": "@schematics/angular:directive" + }, + + "effect": { + "aliases": [ "ef" ], + "factory": "./src/effect", + "schema": "./src/effect/schema.json", + "description": "Add side effect class" + }, + + "entity": { + "aliases": [ "en" ], + "factory": "./src/entity", + "schema": "./src/entity/schema.json", + "description": "Add entity state" + }, + + "enum": { + "aliases": [ "e" ], + "extends": "@schematics/angular:enum" + }, + + "feature": { + "aliases": [ "f" ], + "factory": "./src/feature", + "schema": "./src/feature/schema.json", + "description": "Add feature state" + }, + + "guard": { + "aliases": [ "g" ], + "extends": "@schematics/angular:guard" + }, + + "interface": { + "aliases": [ "i" ], + "extends": "@schematics/angular:interface" + }, + + "module": { + "aliases": [ "m" ], + "extends": "@schematics/angular:module" + }, + + "pipe": { + "aliases": [ "p" ], + "extends": "@schematics/angular:pipe" + }, + + "reducer": { + "factory": "./src/reducer", + "schema": "./src/reducer/schema.json", + "description": "Add state reducer" + }, + + "service": { + "aliases": [ "s" ], + "extends": "@schematics/angular:service" + }, + + "store": { + "factory": "./src/store", + "schema": "./src/store/schema.json", + "description": "Adds initial setup for state managment" + } + } +} diff --git a/modules/schematics/package.json b/modules/schematics/package.json new file mode 100644 index 0000000000..637c2133b2 --- /dev/null +++ b/modules/schematics/package.json @@ -0,0 +1,24 @@ +{ + "name": "@ngrx/schematics", + "version": "4.1.1", + "description": "NgRx Schematics for Angular", + "repository": { + "type": "git", + "url": "git+https://github.com/ngrx/platform.git" + }, + "keywords": [ + "RxJS", + "Angular", + "Redux", + "NgRx", + "Schematics", + "Angular CLI" + ], + "author": "Brandon Roberts ", + "license": "MIT", + "bugs": { + "url": "https://github.com/ngrx/platform/issues" + }, + "homepage": "https://github.com/ngrx/platform#readme", + "schematics": "./collection.json" +} diff --git a/modules/schematics/src/action/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.actions.ts b/modules/schematics/src/action/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.actions.ts new file mode 100644 index 0000000000..a2730780cf --- /dev/null +++ b/modules/schematics/src/action/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.actions.ts @@ -0,0 +1,11 @@ +import { Action } from '@ngrx/store'; + +export enum <%= classify(name) %>ActionTypes { + <%= classify(name) %>Action = '[<%= classify(name) %>] Action' +} + +export class <%= classify(name) %> implements Action { + readonly type = <%= classify(name) %>ActionTypes.<%= classify(name) %>Action; +} + +export type <%= classify(name) %>Actions = <%= classify(name) %>; diff --git a/modules/schematics/src/action/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.actions__dot__spec.ts b/modules/schematics/src/action/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.actions__dot__spec.ts new file mode 100644 index 0000000000..95819022eb --- /dev/null +++ b/modules/schematics/src/action/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.actions__dot__spec.ts @@ -0,0 +1,7 @@ +import { <%= classify(name) %> } from './<%= dasherize(name) %>.actions'; + +describe('<%= classify(name) %>', () => { + it('should create an instance', () => { + expect(new <%= classify(name) %>()).toBeTruthy(); + }); +}); diff --git a/modules/schematics/src/action/index.spec.ts b/modules/schematics/src/action/index.spec.ts new file mode 100644 index 0000000000..175c791667 --- /dev/null +++ b/modules/schematics/src/action/index.spec.ts @@ -0,0 +1,47 @@ +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import * as path from 'path'; +import { getFileContent } from '../utility/test'; +import { Schema as ActionOptions } from './schema'; + +describe('Action Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@ngrx/schematics', + path.join(__dirname, '../../collection.json') + ); + const defaultOptions: ActionOptions = { + name: 'foo', + path: 'app', + sourceDir: 'src', + spec: false, + }; + + it('should create one file', () => { + const tree = schematicRunner.runSchematic('action', defaultOptions); + expect(tree.files.length).toEqual(1); + expect(tree.files[0]).toEqual('/src/app/foo.actions.ts'); + }); + + it('should create two files if spec is true', () => { + const options = { + ...defaultOptions, + spec: true, + }; + const tree = schematicRunner.runSchematic('action', options); + expect(tree.files.length).toEqual(2); + expect( + tree.files.indexOf('/src/app/foo.actions.spec.ts') + ).toBeGreaterThanOrEqual(0); + expect( + tree.files.indexOf('/src/app/foo.actions.ts') + ).toBeGreaterThanOrEqual(0); + }); + + it('should create an enum named "Foo"', () => { + const tree = schematicRunner.runSchematic('action', defaultOptions); + const fileEntry = tree.get(tree.files[0]); + if (fileEntry) { + const fileContent = fileEntry.content.toString(); + expect(fileContent).toMatch(/export enum FooActionTypes/); + } + }); +}); diff --git a/modules/schematics/src/action/index.ts b/modules/schematics/src/action/index.ts new file mode 100644 index 0000000000..8a8426581b --- /dev/null +++ b/modules/schematics/src/action/index.ts @@ -0,0 +1,37 @@ +import { normalize } from '@angular-devkit/core'; +import { + Rule, + SchematicsException, + apply, + branchAndMerge, + chain, + filter, + mergeWith, + move, + noop, + template, + url, +} from '@angular-devkit/schematics'; +import * as stringUtils from '../strings'; +import { Schema as ActionOptions } from './schema'; + +export default function(options: ActionOptions): Rule { + options.path = options.path ? normalize(options.path) : options.path; + const sourceDir = options.sourceDir; + if (!sourceDir) { + throw new SchematicsException(`sourceDir option is required.`); + } + + const templateSource = apply(url('./files'), [ + options.spec ? noop() : filter(path => !path.endsWith('__spec.ts')), + template({ + 'if-flat': (s: string) => (options.flat ? '' : s), + ...stringUtils, + ...(options as object), + dot: () => '.', + }), + move(sourceDir), + ]); + + return chain([branchAndMerge(chain([mergeWith(templateSource)]))]); +} diff --git a/modules/schematics/src/action/schema.d.ts b/modules/schematics/src/action/schema.d.ts new file mode 100644 index 0000000000..fc0cfb1914 --- /dev/null +++ b/modules/schematics/src/action/schema.d.ts @@ -0,0 +1,11 @@ +export interface Schema { + name: string; + appRoot?: string; + path?: string; + sourceDir?: string; + /** + * Specifies if a spec file is generated. + */ + spec?: boolean; + flat?: boolean; +} diff --git a/modules/schematics/src/action/schema.json b/modules/schematics/src/action/schema.json new file mode 100644 index 0000000000..48d043dbf8 --- /dev/null +++ b/modules/schematics/src/action/schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsNgRxAction", + "title": "NgRx Action Options Schema", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "appRoot": { + "type": "string" + }, + "path": { + "type": "string", + "default": "app" + }, + "sourceDir": { + "type": "string", + "default": "src" + }, + "spec": { + "type": "boolean", + "description": "Specifies if a spec file is generated.", + "default": false + }, + "flat": { + "type": "boolean", + "default": true, + "description": "Flag to indicate if a dir is created." + } + }, + "required": [ + "name" + ] +} diff --git a/modules/schematics/src/container/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.__styleext__ b/modules/schematics/src/container/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.__styleext__ new file mode 100644 index 0000000000..e69de29bb2 diff --git a/modules/schematics/src/container/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.html b/modules/schematics/src/container/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.html new file mode 100644 index 0000000000..6c17d15fd5 --- /dev/null +++ b/modules/schematics/src/container/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.html @@ -0,0 +1,3 @@ +<% if (htmlTemplate) { %><%= htmlTemplate %><% } else { %>

+ <%= dasherize(name) %> works! +

<% } %> diff --git a/modules/schematics/src/container/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.ts b/modules/schematics/src/container/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.ts new file mode 100644 index 0000000000..5ebd9667a4 --- /dev/null +++ b/modules/schematics/src/container/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.ts @@ -0,0 +1,26 @@ +import { Component, OnInit<% if(!!viewEncapsulation) { %>, ViewEncapsulation<% }%><% if(changeDetection !== 'Default') { %>, ChangeDetectionStrategy<% }%> } from '@angular/core'; +import { Store } from '@ngrx/store'; +<% if(state) { %>import * as fromStore from '<%= state %>';<% } %> + +@Component({ + selector: '<%= selector %>',<% if(inlineTemplate) { %> + template: ` +

+ <%= dasherize(name) %> works! +

+ `,<% } else { %> + templateUrl: './<%= dasherize(name) %>.component.html',<% } if(inlineStyle) { %> + styles: []<% } else { %> + styleUrls: ['./<%= dasherize(name) %>.component.<%= styleext %>']<% } %><% if(!!viewEncapsulation) { %>, + encapsulation: ViewEncapsulation.<%= viewEncapsulation %><% } if (changeDetection !== 'Default') { %>, + changeDetection: ChangeDetectionStrategy.<%= changeDetection %><% } %> +}) +export class <%= classify(name) %>Component implements OnInit { + + constructor(private store: Store<<% if(state && stateInterface) { %>fromStore.<%= stateInterface %><% } else { %>any<% }%>>) { } + + ngOnInit() { + + } + +} diff --git a/modules/schematics/src/container/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component__dot__spec.ts b/modules/schematics/src/container/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component__dot__spec.ts new file mode 100644 index 0000000000..dac42349dc --- /dev/null +++ b/modules/schematics/src/container/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component__dot__spec.ts @@ -0,0 +1,32 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { <%= classify(name) %>Component } from './<%= dasherize(name) %>.component'; +import { Store, StoreModule } from '@ngrx/store'; + +describe('<%= classify(name) %>Component', () => { + let component: <%= classify(name) %>Component; + let fixture: ComponentFixture<<%= classify(name) %>Component>; + let store: Store; + + beforeEach(async() => { + TestBed.configureTestingModule({ + imports: [ StoreModule.forRoot({}) ], + declarations: [ <%= classify(name) %>Component ] + }); + + await TestBed.compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(<%= classify(name) %>Component); + component = fixture.componentInstance; + store = TestBed.get(Store); + + spyOn(store, 'dispatch').and.callThrough(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/modules/schematics/src/container/index.spec.ts b/modules/schematics/src/container/index.spec.ts new file mode 100644 index 0000000000..2ebd11665c --- /dev/null +++ b/modules/schematics/src/container/index.spec.ts @@ -0,0 +1,266 @@ +import { Tree, VirtualTree } from '@angular-devkit/schematics'; +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import * as path from 'path'; +import { createAppModule, getFileContent } from '../utility/test'; +import { Schema as ContainerOptions } from './schema'; + +describe('Container Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@ngrx/schematics', + path.join(__dirname, '../../collection.json') + ); + const defaultOptions: ContainerOptions = { + name: 'foo', + path: 'app', + sourceDir: 'src', + inlineStyle: false, + inlineTemplate: false, + changeDetection: 'Default', + routing: false, + styleext: 'css', + spec: true, + module: undefined, + export: false, + prefix: undefined, + }; + + let appTree: Tree; + + beforeEach(() => { + appTree = new VirtualTree(); + appTree = createAppModule(appTree); + }); + + it('should create a container component', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('container', options, appTree); + const files = tree.files; + expect( + files.indexOf('/src/app/foo/foo.component.css') + ).toBeGreaterThanOrEqual(0); + expect( + files.indexOf('/src/app/foo/foo.component.html') + ).toBeGreaterThanOrEqual(0); + expect( + files.indexOf('/src/app/foo/foo.component.spec.ts') + ).toBeGreaterThanOrEqual(0); + expect( + files.indexOf('/src/app/foo/foo.component.ts') + ).toBeGreaterThanOrEqual(0); + const moduleContent = getFileContent(tree, '/src/app/app.module.ts'); + expect(moduleContent).toMatch(/import.*Foo.*from '.\/foo\/foo.component'/); + expect(moduleContent).toMatch( + /declarations:\s*\[[^\]]+?,\r?\n\s+FooComponent\r?\n/m + ); + }); + + it('should set change detection to OnPush', () => { + const options = { ...defaultOptions, changeDetection: 'OnPush' }; + + const tree = schematicRunner.runSchematic('container', options, appTree); + const tsContent = getFileContent(tree, '/src/app/foo/foo.component.ts'); + expect(tsContent).toMatch( + /changeDetection: ChangeDetectionStrategy.OnPush/ + ); + }); + + it('should not set view encapsulation', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('container', options, appTree); + const tsContent = getFileContent(tree, '/src/app/foo/foo.component.ts'); + expect(tsContent).not.toMatch(/encapsulation: ViewEncapsulation/); + }); + + it('should set view encapsulation to Emulated', () => { + const options = { ...defaultOptions, viewEncapsulation: 'Emulated' }; + + const tree = schematicRunner.runSchematic('container', options, appTree); + const tsContent = getFileContent(tree, '/src/app/foo/foo.component.ts'); + expect(tsContent).toMatch(/encapsulation: ViewEncapsulation.Emulated/); + }); + + it('should set view encapsulation to None', () => { + const options = { ...defaultOptions, viewEncapsulation: 'None' }; + + const tree = schematicRunner.runSchematic('container', options, appTree); + const tsContent = getFileContent(tree, '/src/app/foo/foo.component.ts'); + expect(tsContent).toMatch(/encapsulation: ViewEncapsulation.None/); + }); + + it('should create a flat component', () => { + const options = { ...defaultOptions, flat: true }; + + const tree = schematicRunner.runSchematic('container', options, appTree); + const files = tree.files; + expect(files.indexOf('/src/app/foo.component.css')).toBeGreaterThanOrEqual( + 0 + ); + expect(files.indexOf('/src/app/foo.component.html')).toBeGreaterThanOrEqual( + 0 + ); + expect( + files.indexOf('/src/app/foo.component.spec.ts') + ).toBeGreaterThanOrEqual(0); + expect(files.indexOf('/src/app/foo.component.ts')).toBeGreaterThanOrEqual( + 0 + ); + }); + + it('should find the closest module', () => { + const options = { ...defaultOptions }; + const fooModule = '/src/app/foo/foo.module.ts'; + appTree.create( + fooModule, + ` + import { NgModule } from '@angular/core'; + + @NgModule({ + imports: [], + declarations: [] + }) + export class FooModule { } + ` + ); + + const tree = schematicRunner.runSchematic('container', options, appTree); + const fooModuleContent = getFileContent(tree, fooModule); + expect(fooModuleContent).toMatch( + /import { FooComponent } from '.\/foo.component'/ + ); + }); + + it('should export the component', () => { + const options = { ...defaultOptions, export: true }; + + const tree = schematicRunner.runSchematic('container', options, appTree); + const appModuleContent = getFileContent(tree, '/src/app/app.module.ts'); + expect(appModuleContent).toMatch(/exports: \[FooComponent\]/); + }); + + it('should import into a specified module', () => { + const options = { ...defaultOptions, module: 'app.module.ts' }; + + const tree = schematicRunner.runSchematic('container', options, appTree); + const appModule = getFileContent(tree, '/src/app/app.module.ts'); + + expect(appModule).toMatch( + /import { FooComponent } from '.\/foo\/foo.component'/ + ); + }); + + it('should fail if specified module does not exist', () => { + const options = { ...defaultOptions, module: '/src/app/app.moduleXXX.ts' }; + let thrownError: Error | null = null; + try { + schematicRunner.runSchematic('container', options, appTree); + } catch (err) { + thrownError = err; + } + expect(thrownError).toBeDefined(); + }); + + it('should handle upper case paths', () => { + const pathOption = 'app/SOME/UPPER/DIR'; + const options = { ...defaultOptions, path: pathOption }; + + const tree = schematicRunner.runSchematic('container', options, appTree); + let files = tree.files; + let root = `/src/${pathOption}/foo/foo.component`; + expect(files.indexOf(`${root}.css`)).toBeGreaterThanOrEqual(0); + expect(files.indexOf(`${root}.html`)).toBeGreaterThanOrEqual(0); + expect(files.indexOf(`${root}.spec.ts`)).toBeGreaterThanOrEqual(0); + expect(files.indexOf(`${root}.ts`)).toBeGreaterThanOrEqual(0); + + const options2 = { ...options, name: 'BAR' }; + const tree2 = schematicRunner.runSchematic('container', options2, tree); + files = tree2.files; + root = `/src/${pathOption}/bar/bar.component`; + expect(files.indexOf(`${root}.css`)).toBeGreaterThanOrEqual(0); + expect(files.indexOf(`${root}.html`)).toBeGreaterThanOrEqual(0); + expect(files.indexOf(`${root}.spec.ts`)).toBeGreaterThanOrEqual(0); + expect(files.indexOf(`${root}.ts`)).toBeGreaterThanOrEqual(0); + }); + + it('should create a component in a sub-directory', () => { + const options = { ...defaultOptions, path: 'app/a/b/c' }; + + const tree = schematicRunner.runSchematic('container', options, appTree); + const files = tree.files; + const root = `/src/${options.path}/foo/foo.component`; + expect(files.indexOf(`${root}.css`)).toBeGreaterThanOrEqual(0); + expect(files.indexOf(`${root}.html`)).toBeGreaterThanOrEqual(0); + expect(files.indexOf(`${root}.spec.ts`)).toBeGreaterThanOrEqual(0); + expect(files.indexOf(`${root}.ts`)).toBeGreaterThanOrEqual(0); + }); + + it('should handle ".." in a path', () => { + const options = { ...defaultOptions, path: 'app/a/../c' }; + + const tree = schematicRunner.runSchematic('container', options, appTree); + const files = tree.files; + const root = `/src/app/c/foo/foo.component`; + expect(files.indexOf(`${root}.css`)).toBeGreaterThanOrEqual(0); + expect(files.indexOf(`${root}.html`)).toBeGreaterThanOrEqual(0); + expect(files.indexOf(`${root}.spec.ts`)).toBeGreaterThanOrEqual(0); + expect(files.indexOf(`${root}.ts`)).toBeGreaterThanOrEqual(0); + }); + + it('should use the prefix', () => { + const options = { ...defaultOptions, prefix: 'pre' }; + + const tree = schematicRunner.runSchematic('container', options, appTree); + const content = getFileContent(tree, '/src/app/foo/foo.component.ts'); + expect(content).toMatch(/selector: 'pre-foo'/); + }); + + it('should not use a prefix if none is passed', () => { + const options = { ...defaultOptions, prefix: '' }; + + const tree = schematicRunner.runSchematic('container', options, appTree); + const content = getFileContent(tree, '/src/app/foo/foo.component.ts'); + expect(content).toMatch(/selector: 'foo'/); + }); + + it('should respect the inlineTemplate option', () => { + const options = { ...defaultOptions, inlineTemplate: true }; + const tree = schematicRunner.runSchematic('container', options, appTree); + const content = getFileContent(tree, '/src/app/foo/foo.component.ts'); + expect(content).toMatch(/template: /); + expect(content).not.toMatch(/templateUrl: /); + expect(tree.files.indexOf('/src/app/foo/foo.component.html')).toEqual(-1); + }); + + it('should respect the inlineStyle option', () => { + const options = { ...defaultOptions, inlineStyle: true }; + const tree = schematicRunner.runSchematic('container', options, appTree); + const content = getFileContent(tree, '/src/app/foo/foo.component.ts'); + expect(content).toMatch(/styles: \[/); + expect(content).not.toMatch(/styleUrls: /); + expect(tree.files.indexOf('/src/app/foo/foo.component.css')).toEqual(-1); + }); + + it('should respect the styleext option', () => { + const options = { ...defaultOptions, styleext: 'scss' }; + const tree = schematicRunner.runSchematic('container', options, appTree); + const content = getFileContent(tree, '/src/app/foo/foo.component.ts'); + expect(content).toMatch(/styleUrls: \['.\/foo.component.scss/); + expect( + tree.files.indexOf('/src/app/foo/foo.component.scss') + ).toBeGreaterThanOrEqual(0); + expect(tree.files.indexOf('/src/app/foo/foo.component.css')).toEqual(-1); + }); + + it('should use the module flag even if the module is a routing module', () => { + const routingFileName = 'app-routing.module.ts'; + const routingModulePath = `/src/app/${routingFileName}`; + const newTree = createAppModule(appTree, routingModulePath); + const options = { ...defaultOptions, module: routingFileName }; + const tree = schematicRunner.runSchematic('container', options, newTree); + const content = getFileContent(tree, routingModulePath); + expect(content).toMatch( + /import { FooComponent } from '.\/foo\/foo.component/ + ); + }); +}); diff --git a/modules/schematics/src/container/index.ts b/modules/schematics/src/container/index.ts new file mode 100644 index 0000000000..09821057c3 --- /dev/null +++ b/modules/schematics/src/container/index.ts @@ -0,0 +1,155 @@ +import { normalize } from '@angular-devkit/core'; +import { + Rule, + SchematicContext, + SchematicsException, + Tree, + apply, + branchAndMerge, + chain, + filter, + mergeWith, + move, + noop, + template, + url, +} from '@angular-devkit/schematics'; +import 'rxjs/add/operator/merge'; +import * as ts from 'typescript'; +import * as stringUtils from '../strings'; +import { + addDeclarationToModule, + addExportToModule, +} from '../utility/ast-utils'; +import { InsertChange } from '../utility/change'; +import { + buildRelativePath, + findModuleFromOptions, +} from '../utility/find-module'; +import { Schema as ContainerOptions } from './schema'; + +function addDeclarationToNgModule(options: ContainerOptions): Rule { + return (host: Tree) => { + if (options.skipImport || !options.module) { + return host; + } + + const modulePath = options.module; + const text = host.read(modulePath); + if (text === null) { + throw new SchematicsException(`File ${modulePath} does not exist.`); + } + const sourceText = text.toString('utf-8'); + const source = ts.createSourceFile( + modulePath, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + const componentPath = + `/${options.sourceDir}/${options.path}/` + + (options.flat ? '' : stringUtils.dasherize(options.name) + '/') + + stringUtils.dasherize(options.name) + + '.component'; + const relativePath = buildRelativePath(modulePath, componentPath); + const classifiedName = stringUtils.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. + const text = host.read(modulePath); + if (text === null) { + throw new SchematicsException(`File ${modulePath} does not exist.`); + } + const sourceText = text.toString('utf-8'); + const source = ts.createSourceFile( + modulePath, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + const exportRecorder = host.beginUpdate(modulePath); + const exportChanges = addExportToModule( + source, + modulePath, + stringUtils.classify(`${options.name}Component`), + relativePath + ); + + for (const change of exportChanges) { + if (change instanceof InsertChange) { + exportRecorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(exportRecorder); + } + + return host; + }; +} + +function buildSelector(options: ContainerOptions) { + let selector = stringUtils.dasherize(options.name); + if (options.prefix) { + selector = `${options.prefix}-${selector}`; + } + + return selector; +} + +export default function(options: ContainerOptions): Rule { + const sourceDir = options.sourceDir; + if (!sourceDir) { + throw new SchematicsException(`sourceDir option is required.`); + } + + return (host: Tree, context: SchematicContext) => { + options.selector = options.selector || buildSelector(options); + options.path = options.path ? normalize(options.path) : options.path; + options.module = findModuleFromOptions(host, options); + + const statePath = `/${options.sourceDir}/${options.path}/${options.state}`; + const componentPath = + `/${options.sourceDir}/${options.path}/` + + (options.flat ? '' : stringUtils.dasherize(options.name) + '/') + + stringUtils.dasherize(options.name) + + '.component'; + options.state = buildRelativePath(componentPath, statePath); + + const templateSource = apply(url('./files'), [ + options.spec ? noop() : filter(path => !path.endsWith('__spec.ts')), + options.inlineStyle + ? filter(path => !path.endsWith('.__styleext__')) + : noop(), + options.inlineTemplate ? filter(path => !path.endsWith('.html')) : noop(), + template({ + ...stringUtils, + 'if-flat': (s: string) => (options.flat ? '' : s), + ...(options as object), + dot: () => '.', + }), + move(sourceDir), + ]); + + return chain([ + branchAndMerge( + chain([addDeclarationToNgModule(options), mergeWith(templateSource)]) + ), + ])(host, context); + }; +} diff --git a/modules/schematics/src/container/schema.d.ts b/modules/schematics/src/container/schema.d.ts new file mode 100644 index 0000000000..72d814456c --- /dev/null +++ b/modules/schematics/src/container/schema.d.ts @@ -0,0 +1,56 @@ +export interface Schema { + path?: string; + appRoot?: string; + sourceDir?: string; + name: string; + /** + * Specifies if the style will be in the ts file. + */ + inlineStyle?: boolean; + /** + * Specifies if the template will be in the ts file. + */ + inlineTemplate?: boolean; + /** + * Specifies the view encapsulation strategy. + */ + viewEncapsulation?: 'Emulated' | 'Native' | 'None'; + /** + * Specifies the change detection strategy. + */ + changeDetection?: 'Default' | 'OnPush'; + routing?: boolean; + /** + * The prefix to apply to generated selectors. + */ + prefix?: string; + /** + * The file extension to be used for style files. + */ + styleext?: string; + spec?: boolean; + /** + * Flag to indicate if a dir is created. + */ + flat?: boolean; + htmlTemplate?: string; + skipImport?: boolean; + selector?: string; + /** + * Allows specification of the declaring module. + */ + module?: string; + /** + * Specifies if declaring module exports the component. + */ + export?: boolean; + /** + * Specifies the path to the state exports + */ + state?: string; + + /** + * Specifies the interface for the state + */ + stateInterface?: string; +} diff --git a/modules/schematics/src/container/schema.json b/modules/schematics/src/container/schema.json new file mode 100644 index 0000000000..396c1fec74 --- /dev/null +++ b/modules/schematics/src/container/schema.json @@ -0,0 +1,106 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsNgrxContainer", + "title": "NgRx Container Options Schema", + "type": "object", + "properties": { + "path": { + "type": "string", + "default": "app" + }, + "appRoot": { + "type": "string" + }, + "sourceDir": { + "type": "string", + "default": "src", + "alias": "sd" + }, + "name": { + "type": "string" + }, + "inlineStyle": { + "description": "Specifies if the style will be in the ts file.", + "type": "boolean", + "default": false, + "alias": "is" + }, + "inlineTemplate": { + "description": "Specifies if the template will be in the ts file.", + "type": "boolean", + "default": false, + "alias": "it" + }, + "viewEncapsulation": { + "description": "Specifies the view encapsulation strategy.", + "enum": ["Emulated", "Native", "None"], + "type": "string", + "alias": "ve" + }, + "changeDetection": { + "description": "Specifies the change detection strategy.", + "enum": ["Default", "OnPush"], + "type": "string", + "default": "Default", + "alias": "cd" + }, + "routing": { + "type": "boolean", + "default": false + }, + "prefix": { + "type": "string", + "description": "The prefix to apply to generated selectors.", + "default": "app", + "alias": "p" + }, + "styleext": { + "description": "The file extension to be used for style files.", + "type": "string", + "default": "css" + }, + "spec": { + "type": "boolean", + "default": true + }, + "flat": { + "type": "boolean", + "description": "Flag to indicate if a dir is created.", + "default": false + }, + "htmlTemplate": { + "type": "string" + }, + "skipImport": { + "type": "boolean", + "default": false + }, + "selector": { + "type": "string" + }, + "module": { + "type": "string", + "description": "Allows specification of the declaring module.", + "alias": "m" + }, + "export": { + "type": "boolean", + "default": false, + "description": "Specifies if declaring module exports the component." + }, + "state": { + "type": "string", + "description": "Specifies the path to the state exports.", + "alias": "s" + }, + "stateInterface": { + "type": "string", + "default": "State", + "description": "Specifies the interface for the state.", + "alias": "si" + } + }, + "required": [ + "name" + ] +} diff --git a/modules/schematics/src/effect/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.effects.ts b/modules/schematics/src/effect/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.effects.ts new file mode 100644 index 0000000000..a2bf947cca --- /dev/null +++ b/modules/schematics/src/effect/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.effects.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect } from '@ngrx/effects'; +<% if(feature) { %>import { <%= classify(name) %>Actions, <%= classify(name) %>ActionTypes } from './<%= dasherize(name) %>.actions';<% } %> + +@Injectable() +export class <%= classify(name) %>Effects { +<% if(feature) { %> + @Effect() + effect$ = this.actions$.ofType(<%= classify(name) %>ActionTypes.<%= classify(name) %>Action); +<% } %> + constructor(private actions$: Actions) {} +} diff --git a/modules/schematics/src/effect/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.effects__dot__spec.ts b/modules/schematics/src/effect/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.effects__dot__spec.ts new file mode 100644 index 0000000000..92b55a8aa4 --- /dev/null +++ b/modules/schematics/src/effect/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.effects__dot__spec.ts @@ -0,0 +1,25 @@ +import { TestBed, inject } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Observable } from 'rxjs/Observable'; + +import { <%= classify(name) %>Effects } from './<%= dasherize(name) %>.effects'; + +describe('<%= classify(name) %>Service', () => { + let actions$: Observable; + let effects: <%= classify(name) %>Effects; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + <%= classify(name) %>Effects, + provideMockActions(() => actions$) + ] + }); + + effects = TestBed.get(<%= classify(name) %>Effects); + }); + + it('should be created', () => { + expect(effects).toBeTruthy(); + }); +}); diff --git a/modules/schematics/src/effect/index.spec.ts b/modules/schematics/src/effect/index.spec.ts new file mode 100644 index 0000000000..84a0a0fac5 --- /dev/null +++ b/modules/schematics/src/effect/index.spec.ts @@ -0,0 +1,80 @@ +import { Tree, VirtualTree } from '@angular-devkit/schematics'; +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import * as path from 'path'; +import { createAppModule, getFileContent } from '../utility/test'; +import { Schema as EffectOptions } from './schema'; + +describe('Effect Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@ngrx/schematics', + path.join(__dirname, '../../collection.json') + ); + const defaultOptions: EffectOptions = { + name: 'foo', + path: 'app', + sourceDir: 'src', + spec: true, + module: undefined, + flat: false, + }; + + let appTree: Tree; + + beforeEach(() => { + appTree = new VirtualTree(); + appTree = createAppModule(appTree); + }); + + it('should create an effect', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('effect', options, appTree); + const files = tree.files; + expect( + files.indexOf('/src/app/foo/foo.effects.spec.ts') + ).toBeGreaterThanOrEqual(0); + expect(files.indexOf('/src/app/foo/foo.effects.ts')).toBeGreaterThanOrEqual( + 0 + ); + }); + + it('should not be provided by default', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('effect', options, appTree); + const content = getFileContent(tree, '/src/app/app.module.ts'); + expect(content).not.toMatch( + /import { FooEffects } from '.\/foo\/foo.effects'/ + ); + }); + + it('should import into a specified module', () => { + const options = { ...defaultOptions, module: 'app.module.ts' }; + + const tree = schematicRunner.runSchematic('effect', options, appTree); + const content = getFileContent(tree, '/src/app/app.module.ts'); + expect(content).toMatch(/import { FooEffects } from '.\/foo\/foo.effects'/); + }); + + it('should fail if specified module does not exist', () => { + const options = { ...defaultOptions, module: '/src/app/app.moduleXXX.ts' }; + let thrownError: Error | null = null; + try { + schematicRunner.runSchematic('effects', options, appTree); + } catch (err) { + thrownError = err; + } + expect(thrownError).toBeDefined(); + }); + + it('should respect the spec flag', () => { + const options = { ...defaultOptions, spec: false }; + + const tree = schematicRunner.runSchematic('effect', options, appTree); + const files = tree.files; + expect(files.indexOf('/src/app/foo/foo.effects.ts')).toBeGreaterThanOrEqual( + 0 + ); + expect(files.indexOf('/src/app/foo/foo.effects.spec.ts')).toEqual(-1); + }); +}); diff --git a/modules/schematics/src/effect/index.ts b/modules/schematics/src/effect/index.ts new file mode 100644 index 0000000000..5caaca7398 --- /dev/null +++ b/modules/schematics/src/effect/index.ts @@ -0,0 +1,132 @@ +import { normalize } from '@angular-devkit/core'; +import { + Rule, + SchematicContext, + SchematicsException, + Tree, + apply, + branchAndMerge, + chain, + filter, + mergeWith, + move, + noop, + template, + url, +} from '@angular-devkit/schematics'; +import 'rxjs/add/operator/merge'; +import * as ts from 'typescript'; +import * as stringUtils from '../strings'; +import { addProviderToModule, addImportToModule } from '../utility/ast-utils'; +import { InsertChange } from '../utility/change'; +import { + buildRelativePath, + findModuleFromOptions, +} from '../utility/find-module'; +import { Schema as EffectOptions } from './schema'; +import { insertImport } from '../utility/route-utils'; + +function addImportToNgModule(options: EffectOptions): Rule { + return (host: Tree) => { + if (!options.module) { + return host; + } + + const modulePath = options.module; + if (!host.exists(options.module)) { + throw new Error('Specified module does not exist'); + } + + const text = host.read(modulePath); + if (text === null) { + throw new SchematicsException(`File ${modulePath} does not exist.`); + } + const sourceText = text.toString('utf-8'); + + const source = ts.createSourceFile( + modulePath, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + const effectsName = `${stringUtils.classify(`${options.name}Effects`)}`; + + const effectsModuleImport = insertImport( + source, + modulePath, + 'EffectsModule', + '@ngrx/effects' + ); + + const effectsPath = + `/${options.sourceDir}/${options.path}/` + + (options.flat ? '' : stringUtils.dasherize(options.name) + '/') + + stringUtils.dasherize(options.name) + + '.effects'; + const relativePath = buildRelativePath(modulePath, effectsPath); + const effectsImport = insertImport( + source, + modulePath, + effectsName, + relativePath + ); + const [effectsNgModuleImport] = addImportToModule( + source, + modulePath, + options.root + ? `EffectsModule.forRoot([${effectsName}])` + : `EffectsModule.forFeature([${effectsName}])`, + relativePath + ); + const changes = [effectsModuleImport, effectsImport, effectsNgModuleImport]; + const recorder = host.beginUpdate(modulePath); + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(recorder); + + return host; + }; +} + +export default function(options: EffectOptions): Rule { + options.path = options.path ? normalize(options.path) : options.path; + const sourceDir = options.sourceDir; + if (!sourceDir) { + throw new SchematicsException(`sourceDir option is required.`); + } + + return (host: Tree, context: SchematicContext) => { + if (options.module) { + options.module = findModuleFromOptions(host, options); + } + + const templateSource = apply(url('./files'), [ + options.spec ? noop() : filter(path => !path.endsWith('__spec.ts')), + template({ + ...stringUtils, + 'if-flat': (s: string) => (options.flat ? '' : s), + ...(options as object), + dot: () => '.', + }), + move(sourceDir), + ]); + + return chain([ + branchAndMerge( + chain([ + filter( + path => + path.endsWith('.module.ts') && + !path.endsWith('-routing.module.ts') + ), + addImportToNgModule(options), + mergeWith(templateSource), + ]) + ), + ])(host, context); + }; +} diff --git a/modules/schematics/src/effect/schema.d.ts b/modules/schematics/src/effect/schema.d.ts new file mode 100644 index 0000000000..5560cbcb96 --- /dev/null +++ b/modules/schematics/src/effect/schema.d.ts @@ -0,0 +1,20 @@ +export interface Schema { + name: string; + path?: string; + appRoot?: string; + sourceDir?: string; + /** + * Flag to indicate if a dir is created. + */ + flat?: boolean; + /** + * Specifies if a spec file is generated. + */ + spec?: boolean; + /** + * Allows specification of the declaring module. + */ + module?: string; + root?: boolean; + feature?: boolean; +} diff --git a/modules/schematics/src/effect/schema.json b/modules/schematics/src/effect/schema.json new file mode 100644 index 0000000000..def9cad38e --- /dev/null +++ b/modules/schematics/src/effect/schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsNgRxEffect", + "title": "NgRx Effect Options Schema", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string", + "default": "app" + }, + "appRoot": { + "type": "string" + }, + "sourceDir": { + "type": "string", + "default": "src" + }, + "flat": { + "type": "boolean", + "default": true, + "description": "Flag to indicate if a dir is created." + }, + "spec": { + "type": "boolean", + "default": true, + "description": "Specifies if a spec file is generated." + }, + "module": { + "type": "string", + "default": "", + "description": "Allows specification of the declaring module.", + "alias": "m", + "subtype": "filepath" + }, + "root": { + "type": "boolean", + "default": false, + "description": "Flag to indicate whether the effects are registered as root." + }, + "feature": { + "type": "boolean", + "default": false, + "description": "Flag to indicate if part of a feature schematic." + } + }, + "required": [ + "name" + ] +} diff --git a/modules/schematics/src/entity/files/__path__/__name@dasherize__.actions.ts b/modules/schematics/src/entity/files/__path__/__name@dasherize__.actions.ts new file mode 100644 index 0000000000..5b79b7c2bb --- /dev/null +++ b/modules/schematics/src/entity/files/__path__/__name@dasherize__.actions.ts @@ -0,0 +1,69 @@ +import { Action } from '@ngrx/store'; +import { <%= classify(name) %> } from './<%= dasherize(name) %>.model'; + +export enum <%= classify(name) %>ActionTypes { + Load<%= classify(name) %>s = '[<%= classify(name) %>] Load <%= classify(name) %>s', + Add<%= classify(name) %> = '[<%= classify(name) %>] Add <%= classify(name) %>', + Add<%= classify(name) %>s = '[<%= classify(name) %>] Add <%= classify(name) %>s', + Update<%= classify(name) %> = '[<%= classify(name) %>] Update <%= classify(name) %>', + Update<%= classify(name) %>s = '[<%= classify(name) %>] Update <%= classify(name) %>s', + Delete<%= classify(name) %> = '[<%= classify(name) %>] Delete <%= classify(name) %>', + Delete<%= classify(name) %>s = '[<%= classify(name) %>] Delete <%= classify(name) %>s', + Clear<%= classify(name) %>s = '[<%= classify(name) %>] Clear <%= classify(name) %>s' +} + +export class Load<%= classify(name) %>s implements Action { + readonly type = <%= classify(name) %>ActionTypes.Load<%= classify(name) %>s; + + constructor(public payload: { <%= lowercase(name) %>s: <%= classify(name) %>[] }) {} +} + +export class Add<%= classify(name) %> implements Action { + readonly type = <%= classify(name) %>ActionTypes.Add<%= classify(name) %>; + + constructor(public payload: { <%= lowercase(name) %>: <%= classify(name) %> }) {} +} + +export class Add<%= classify(name) %>s implements Action { + readonly type = <%= classify(name) %>ActionTypes.Add<%= classify(name) %>s; + + constructor(public payload: { <%= lowercase(name) %>s: <%= classify(name) %>[] }) {} +} + +export class Update<%= classify(name) %> implements Action { + readonly type = <%= classify(name) %>ActionTypes.Update<%= classify(name) %>; + + constructor(public payload: { <%= lowercase(name) %>: { id: string, changes: <%= classify(name) %> } }) {} +} + +export class Update<%= classify(name) %>s implements Action { + readonly type = <%= classify(name) %>ActionTypes.Update<%= classify(name) %>s; + + constructor(public payload: { <%= lowercase(name) %>s: { id: string, changes: <%= classify(name) %> }[] }) {} +} + +export class Delete<%= classify(name) %> implements Action { + readonly type = <%= classify(name) %>ActionTypes.Delete<%= classify(name) %>; + + constructor(public payload: { id: string }) {} +} + +export class Delete<%= classify(name) %>s implements Action { + readonly type = <%= classify(name) %>ActionTypes.Delete<%= classify(name) %>s; + + constructor(public payload: { ids: string[] }) {} +} + +export class Clear<%= classify(name) %>s implements Action { + readonly type = <%= classify(name) %>ActionTypes.Clear<%= classify(name) %>s; +} + +export type <%= classify(name) %>Actions = + Load<%= classify(name) %>s + | Add<%= classify(name) %> + | Add<%= classify(name) %>s + | Update<%= classify(name) %> + | Update<%= classify(name) %>s + | Delete<%= classify(name) %> + | Delete<%= classify(name) %>s + | Clear<%= classify(name) %>s; diff --git a/modules/schematics/src/entity/files/__path__/__name@dasherize__.model.ts b/modules/schematics/src/entity/files/__path__/__name@dasherize__.model.ts new file mode 100644 index 0000000000..993c019780 --- /dev/null +++ b/modules/schematics/src/entity/files/__path__/__name@dasherize__.model.ts @@ -0,0 +1,3 @@ +export interface <%= classify(name) %> { + id: string; +} diff --git a/modules/schematics/src/entity/files/__path__/__name@dasherize__.reducer.ts b/modules/schematics/src/entity/files/__path__/__name@dasherize__.reducer.ts new file mode 100644 index 0000000000..cf2ad4e492 --- /dev/null +++ b/modules/schematics/src/entity/files/__path__/__name@dasherize__.reducer.ts @@ -0,0 +1,63 @@ +import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; +import { <%= classify(name) %> } from './<%= dasherize(name) %>.model'; +import { <%= classify(name) %>Actions, <%= classify(name) %>ActionTypes } from './<%= dasherize(name) %>.actions'; + +export interface State extends EntityState<<%= classify(name) %>> { + // additional entities state properties +} + +export const adapter: EntityAdapter<<%= classify(name) %>> = createEntityAdapter<<%= classify(name) %>>(); + +export const initialState: State = adapter.getInitialState({ + // additional entity state properties +}); + +export function reducer( + state = initialState, + action: <%= classify(name) %>Actions +): State { + switch (action.type) { + case <%= classify(name) %>ActionTypes.Add<%= classify(name) %>: { + return adapter.addOne(action.payload.<%= lowercase(classify(name)) %>, state); + } + + case <%= classify(name) %>ActionTypes.Add<%= classify(name) %>s: { + return adapter.addMany(action.payload.<%= lowercase(classify(name)) %>s, state); + } + + case <%= classify(name) %>ActionTypes.Update<%= classify(name) %>: { + return adapter.updateOne(action.payload.<%= lowercase(classify(name)) %>, state); + } + + case <%= classify(name) %>ActionTypes.Update<%= classify(name) %>s: { + return adapter.updateMany(action.payload.<%= lowercase(classify(name)) %>s, state); + } + + case <%= classify(name) %>ActionTypes.Delete<%= classify(name) %>: { + return adapter.removeOne(action.payload.id, state); + } + + case <%= classify(name) %>ActionTypes.Delete<%= classify(name) %>s: { + return adapter.removeMany(action.payload.ids, state); + } + + case <%= classify(name) %>ActionTypes.Load<%= classify(name) %>s: { + return adapter.addAll(action.payload.<%= lowercase(classify(name)) %>s, state); + } + + case <%= classify(name) %>ActionTypes.Clear<%= classify(name) %>s: { + return adapter.removeAll(state); + } + + default: { + return state; + } + } +} + +export const { + selectIds, + selectEntities, + selectAll, + selectTotal, +} = adapter.getSelectors(); diff --git a/modules/schematics/src/entity/files/__path__/__name@dasherize__.reducer__dot__spec.ts b/modules/schematics/src/entity/files/__path__/__name@dasherize__.reducer__dot__spec.ts new file mode 100644 index 0000000000..1e49ab0fab --- /dev/null +++ b/modules/schematics/src/entity/files/__path__/__name@dasherize__.reducer__dot__spec.ts @@ -0,0 +1,13 @@ +import { reducer, initialState } from './<%= dasherize(name) %>.reducer'; + +describe('<%= classify(name) %> Reducer', () => { + describe('unknown action', () => { + it('should return the initial state', () => { + const action = {} as any; + + const result = reducer(initialState, action); + + expect(result).toBe(initialState); + }); + }); +}); diff --git a/modules/schematics/src/entity/index.spec.ts b/modules/schematics/src/entity/index.spec.ts new file mode 100644 index 0000000000..874fca36f0 --- /dev/null +++ b/modules/schematics/src/entity/index.spec.ts @@ -0,0 +1,54 @@ +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import * as path from 'path'; +import { getFileContent, createAppModule } from '../utility/test'; +import { Schema as EntityOptions } from './schema'; +import { Tree, VirtualTree } from '@angular-devkit/schematics'; + +describe('Entity Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@ngrx/schematics', + path.join(__dirname, '../../collection.json') + ); + const defaultOptions: EntityOptions = { + name: 'foo', + path: 'app', + sourceDir: 'src', + spec: false, + }; + + let appTree: Tree; + + beforeEach(() => { + appTree = new VirtualTree(); + appTree = createAppModule(appTree); + }); + + it('should create 3 files', () => { + const tree = schematicRunner.runSchematic('entity', defaultOptions); + expect(tree.files.length).toEqual(3); + expect(tree.files[0]).toEqual('/src/app/foo.actions.ts'); + expect(tree.files[1]).toEqual('/src/app/foo.model.ts'); + expect(tree.files[2]).toEqual('/src/app/foo.reducer.ts'); + }); + + it('should create 4 files if spec is true', () => { + const options = { + ...defaultOptions, + spec: true, + }; + const tree = schematicRunner.runSchematic('entity', options); + expect(tree.files.length).toEqual(4); + expect(tree.files[0]).toEqual('/src/app/foo.actions.ts'); + expect(tree.files[1]).toEqual('/src/app/foo.model.ts'); + expect(tree.files[2]).toEqual('/src/app/foo.reducer.ts'); + expect(tree.files[3]).toEqual('/src/app/foo.reducer.spec.ts'); + }); + + it('should import into a specified module', () => { + const options = { ...defaultOptions, module: 'app.module.ts' }; + + const tree = schematicRunner.runSchematic('entity', options, appTree); + const content = getFileContent(tree, '/src/app/app.module.ts'); + expect(content).toMatch(/import \* as fromFoo from '\.\/foo.reducer';/); + }); +}); diff --git a/modules/schematics/src/entity/index.ts b/modules/schematics/src/entity/index.ts new file mode 100644 index 0000000000..d25d592d29 --- /dev/null +++ b/modules/schematics/src/entity/index.ts @@ -0,0 +1,53 @@ +import { normalize } from '@angular-devkit/core'; +import { + Rule, + SchematicsException, + apply, + branchAndMerge, + chain, + filter, + mergeWith, + move, + noop, + template, + url, + Tree, + SchematicContext, +} from '@angular-devkit/schematics'; +import * as stringUtils from '../strings'; +import { Schema as EntityOptions } from './schema'; +import { + addReducerToState, + addReducerImportToNgModule, +} from '../utility/ngrx-utils'; +import { findModuleFromOptions } from '../utility/find-module'; + +export default function(options: EntityOptions): Rule { + options.path = options.path ? normalize(options.path) : options.path; + const sourceDir = options.sourceDir; + if (!sourceDir) { + throw new SchematicsException(`sourceDir option is required.`); + } + + return (host: Tree, context: SchematicContext) => { + if (options.module) { + options.module = findModuleFromOptions(host, options); + } + + const templateSource = apply(url('./files'), [ + options.spec ? noop() : filter(path => !path.endsWith('__spec.ts')), + template({ + ...stringUtils, + ...(options as object), + dot: () => '.', + }), + move(sourceDir), + ]); + + return chain([ + addReducerToState({ ...options, flat: true }), + addReducerImportToNgModule({ ...options, flat: true }), + branchAndMerge(chain([mergeWith(templateSource)])), + ])(host, context); + }; +} diff --git a/modules/schematics/src/entity/schema.d.ts b/modules/schematics/src/entity/schema.d.ts new file mode 100644 index 0000000000..fba0ade80c --- /dev/null +++ b/modules/schematics/src/entity/schema.d.ts @@ -0,0 +1,12 @@ +export interface Schema { + name: string; + appRoot?: string; + path?: string; + sourceDir?: string; + /** + * Specifies if a spec file is generated. + */ + spec?: boolean; + module?: string; + reducers?: string; +} diff --git a/modules/schematics/src/entity/schema.json b/modules/schematics/src/entity/schema.json new file mode 100644 index 0000000000..acf97c251c --- /dev/null +++ b/modules/schematics/src/entity/schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsNgRxEntity", + "title": "NgRx Entity Options Schema", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "appRoot": { + "type": "string" + }, + "path": { + "type": "string", + "default": "app" + }, + "sourceDir": { + "type": "string", + "default": "src" + }, + "spec": { + "type": "boolean", + "description": "Specifies if a spec file is generated.", + "default": true + }, + "reducers": { + "type": "string", + "description": "Specifies the reducers file.", + "aliases": ["r"] + }, + "module": { + "type": "string", + "description": "Specifies the declaring module.", + "aliases": ["m"] + } + }, + "required": [ + "name" + ] +} diff --git a/modules/schematics/src/feature/index.spec.ts b/modules/schematics/src/feature/index.spec.ts new file mode 100644 index 0000000000..5974f7c6bb --- /dev/null +++ b/modules/schematics/src/feature/index.spec.ts @@ -0,0 +1,37 @@ +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import * as path from 'path'; +import { getFileContent } from '../utility/test'; +import { Schema as FeatureOptions } from './schema'; + +describe('Feature Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@ngrx/schematics', + path.join(__dirname, '../../collection.json') + ); + const defaultOptions: FeatureOptions = { + name: 'foo', + path: 'app', + sourceDir: 'src', + module: '', + spec: true, + }; + + it('should create all files of a feature', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('feature', options); + const files = tree.files; + expect(files.indexOf('/src/app/foo.actions.ts')).toBeGreaterThanOrEqual(0); + expect(files.indexOf('/src/app/foo.reducer.ts')).toBeGreaterThanOrEqual(0); + expect( + files.indexOf('/src/app/foo.reducer.spec.ts') + ).toBeGreaterThanOrEqual(0); + expect(files.indexOf('/src/app/foo.effects.ts')).toBeGreaterThanOrEqual(0); + expect( + files.indexOf('/src/app/foo.effects.spec.ts') + ).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/modules/schematics/src/feature/index.ts b/modules/schematics/src/feature/index.ts new file mode 100644 index 0000000000..dbdd447101 --- /dev/null +++ b/modules/schematics/src/feature/index.ts @@ -0,0 +1,55 @@ +import { + MergeStrategy, + Rule, + SchematicContext, + SchematicsException, + Tree, + apply, + chain, + filter, + mergeWith, + move, + noop, + schematic, + template, + url, +} from '@angular-devkit/schematics'; +import * as ts from 'typescript'; +import * as stringUtils from '../strings'; +import { addBootstrapToModule, addImportToModule } from '../utility/ast-utils'; +import { InsertChange } from '../utility/change'; +import { Schema as FeatureOptions } from './schema'; +import { insertImport } from '../utility/route-utils'; +import { buildRelativePath } from '../utility/find-module'; + +export default function(options: FeatureOptions): Rule { + return (host: Tree, context: SchematicContext) => { + return chain([ + schematic('action', { + name: options.name, + path: options.path, + sourceDir: options.sourceDir, + spec: false, + }), + schematic('reducer', { + flat: options.flat, + module: options.module, + name: options.name, + path: options.path, + sourceDir: options.sourceDir, + spec: options.spec, + reducers: options.reducers, + feature: true, + }), + schematic('effect', { + flat: options.flat, + module: options.module, + name: options.name, + path: options.path, + sourceDir: options.sourceDir, + spec: options.spec, + feature: true, + }), + ])(host, context); + }; +} diff --git a/modules/schematics/src/feature/schema.d.ts b/modules/schematics/src/feature/schema.d.ts new file mode 100644 index 0000000000..4e4aa14ecc --- /dev/null +++ b/modules/schematics/src/feature/schema.d.ts @@ -0,0 +1,9 @@ +export interface Schema { + path?: string; + sourceDir?: string; + name: string; + module?: string; + flat?: boolean; + spec?: boolean; + reducers?: string; +} diff --git a/modules/schematics/src/feature/schema.json b/modules/schematics/src/feature/schema.json new file mode 100644 index 0000000000..5a304ed310 --- /dev/null +++ b/modules/schematics/src/feature/schema.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsNgRxFeature", + "title": "NgRx Feature State Options Schema", + "type": "object", + "properties": { + "path": { + "type": "string", + "default": "src" + }, + "sourceDir": { + "type": "string", + "default": "src", + "alias": "sd" + }, + "name": { + "type": "string" + }, + "flat": { + "type": "boolean", + "default": true, + "description": "Flag to indicate if a dir is created." + }, + "module": { + "type": "string", + "description": "Specifies the declaring module.", + "aliases": ["m"] + }, + "spec": { + "type": "boolean", + "default": true, + "description": "Specifies if a spec file is generated." + }, + "reducers": { + "type": "string", + "description": "Specifies the reducers file.", + "aliases": ["r"] + } + }, + "required": [ + "name" + ] +} diff --git a/modules/schematics/src/reducer/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.reducer.ts b/modules/schematics/src/reducer/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.reducer.ts new file mode 100644 index 0000000000..3e2d5d59c1 --- /dev/null +++ b/modules/schematics/src/reducer/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.reducer.ts @@ -0,0 +1,22 @@ +import { Action } from '@ngrx/store'; +<% if(feature) { %>import { <%= classify(name) %>Actions, <%= classify(name) %>ActionTypes } from './<%= dasherize(name) %>.actions';<% } %> + +export interface State { + +} + +export const initialState: State = { + +}; + +export function reducer(state = initialState, action: <% if(feature) { %><%= classify(name) %>Actions<% } else { %>Action<% } %>): State { + switch (action.type) { +<% if(feature) { %> + case <%= classify(name) %>ActionTypes.<%= classify(name) %>Action: + return state; + +<% } %> + default: + return state; + } +} diff --git a/modules/schematics/src/reducer/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.reducer__dot__spec.ts b/modules/schematics/src/reducer/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.reducer__dot__spec.ts new file mode 100644 index 0000000000..1e49ab0fab --- /dev/null +++ b/modules/schematics/src/reducer/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.reducer__dot__spec.ts @@ -0,0 +1,13 @@ +import { reducer, initialState } from './<%= dasherize(name) %>.reducer'; + +describe('<%= classify(name) %> Reducer', () => { + describe('unknown action', () => { + it('should return the initial state', () => { + const action = {} as any; + + const result = reducer(initialState, action); + + expect(result).toBe(initialState); + }); + }); +}); diff --git a/modules/schematics/src/reducer/index.spec.ts b/modules/schematics/src/reducer/index.spec.ts new file mode 100644 index 0000000000..b1509a9d15 --- /dev/null +++ b/modules/schematics/src/reducer/index.spec.ts @@ -0,0 +1,92 @@ +import { Tree, VirtualTree } from '@angular-devkit/schematics'; +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import * as path from 'path'; +import { createAppModule, getFileContent } from '../utility/test'; +import { Schema as ReducerOptions } from './schema'; +import { createReducers } from '../utility/test/create-reducers'; + +describe('Reducer Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@ngrx/schematics', + path.join(__dirname, '../../collection.json') + ); + const defaultOptions: ReducerOptions = { + name: 'foo', + path: 'app', + sourceDir: 'src', + spec: false, + }; + + let appTree: Tree; + + beforeEach(() => { + appTree = new VirtualTree(); + appTree = createReducers(createAppModule(appTree)); + }); + + it('should create one file', () => { + const tree = schematicRunner.runSchematic('reducer', defaultOptions); + expect(tree.files.length).toEqual(1); + expect(tree.files[0]).toEqual('/src/app/foo.reducer.ts'); + }); + + it('should create two files if spec is true', () => { + const options = { + ...defaultOptions, + spec: true, + }; + const tree = schematicRunner.runSchematic('reducer', options); + expect(tree.files.length).toEqual(2); + expect( + tree.files.indexOf('/src/app/foo.reducer.spec.ts') + ).toBeGreaterThanOrEqual(0); + expect( + tree.files.indexOf('/src/app/foo.reducer.ts') + ).toBeGreaterThanOrEqual(0); + }); + + it('should create an reducer function', () => { + const tree = schematicRunner.runSchematic('reducer', defaultOptions); + const fileEntry = tree.get(tree.files[0]); + if (fileEntry) { + const fileContent = fileEntry.content.toString(); + expect(fileContent).toMatch(/export function reducer/); + } + }); + + it('should import into a specified module', () => { + const options = { ...defaultOptions, module: 'app.module.ts' }; + + const tree = schematicRunner.runSchematic('reducer', options, appTree); + const appModule = getFileContent(tree, '/src/app/app.module.ts'); + + expect(appModule).toMatch(/import \* as fromFoo from '.\/foo.reducer'/); + }); + + it('should import into a specified reducers', () => { + const options = { ...defaultOptions, reducers: 'reducers/index.ts' }; + + const tree = schematicRunner.runSchematic('reducer', options, appTree); + const reducers = getFileContent(tree, '/src/app/reducers/index.ts'); + + expect(reducers).toMatch(/import \* as fromFoo from '..\/foo.reducer'/); + }); + + it('should add the reducer State to the State interface', () => { + const options = { ...defaultOptions, reducers: 'reducers/index.ts' }; + + const tree = schematicRunner.runSchematic('reducer', options, appTree); + const reducers = getFileContent(tree, '/src/app/reducers/index.ts'); + + expect(reducers).toMatch(/foo\: fromFoo.State/); + }); + + it('should add the reducer function to the ActionReducerMap', () => { + const options = { ...defaultOptions, reducers: 'reducers/index.ts' }; + + const tree = schematicRunner.runSchematic('reducer', options, appTree); + const reducers = getFileContent(tree, '/src/app/reducers/index.ts'); + + expect(reducers).toMatch(/foo\: fromFoo.reducer/); + }); +}); diff --git a/modules/schematics/src/reducer/index.ts b/modules/schematics/src/reducer/index.ts new file mode 100644 index 0000000000..6e537dce6e --- /dev/null +++ b/modules/schematics/src/reducer/index.ts @@ -0,0 +1,74 @@ +import { normalize } from '@angular-devkit/core'; +import { + Rule, + SchematicContext, + SchematicsException, + Tree, + apply, + branchAndMerge, + chain, + filter, + mergeWith, + move, + noop, + template, + url, +} from '@angular-devkit/schematics'; +import 'rxjs/add/operator/merge'; +import * as ts from 'typescript'; +import * as stringUtils from '../strings'; +import { addProviderToModule, addImportToModule } from '../utility/ast-utils'; +import { InsertChange, Change } from '../utility/change'; +import { + buildRelativePath, + findModuleFromOptions, +} from '../utility/find-module'; +import { Schema as ReducerOptions } from './schema'; +import { insertImport } from '../utility/route-utils'; +import * as path from 'path'; +import { + addReducerToStateInferface, + addReducerToActionReducerMap, + addReducerToState, + addReducerImportToNgModule, +} from '../utility/ngrx-utils'; + +export default function(options: ReducerOptions): Rule { + options.path = options.path ? normalize(options.path) : options.path; + const sourceDir = options.sourceDir; + if (!sourceDir) { + throw new SchematicsException(`sourceDir option is required.`); + } + + return (host: Tree, context: SchematicContext) => { + if (options.module) { + options.module = findModuleFromOptions(host, options); + } + + const templateSource = apply(url('./files'), [ + options.spec ? noop() : filter(path => !path.endsWith('__spec.ts')), + template({ + ...stringUtils, + 'if-flat': (s: string) => (options.flat ? '' : s), + ...(options as object), + dot: () => '.', + }), + move(sourceDir), + ]); + + return chain([ + addReducerToState(options), + branchAndMerge( + chain([ + filter( + path => + path.endsWith('.module.ts') && + !path.endsWith('-routing.module.ts') + ), + addReducerImportToNgModule(options), + mergeWith(templateSource), + ]) + ), + ])(host, context); + }; +} diff --git a/modules/schematics/src/reducer/schema.d.ts b/modules/schematics/src/reducer/schema.d.ts new file mode 100644 index 0000000000..6cdc84fc38 --- /dev/null +++ b/modules/schematics/src/reducer/schema.d.ts @@ -0,0 +1,17 @@ +export interface Schema { + name: string; + appRoot?: string; + path?: string; + sourceDir?: string; + /** + * Specifies if a spec file is generated. + */ + spec?: boolean; + /** + * Flag to indicate if a dir is created. + */ + flat?: boolean; + module?: string; + feature?: boolean; + reducers?: string; +} diff --git a/modules/schematics/src/reducer/schema.json b/modules/schematics/src/reducer/schema.json new file mode 100644 index 0000000000..58978ed5d9 --- /dev/null +++ b/modules/schematics/src/reducer/schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsNgRxReducer", + "title": "NgRx Reducer Options Schema", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "appRoot": { + "type": "string" + }, + "path": { + "type": "string", + "default": "app" + }, + "sourceDir": { + "type": "string", + "default": "src" + }, + "spec": { + "type": "boolean", + "description": "Specifies if a spec file is generated.", + "default": true + }, + "module": { + "type": "string", + "description": "Specifies the declaring module.", + "aliases": ["m"] + }, + "flat": { + "type": "boolean", + "default": true, + "description": "Flag to indicate if a dir is created." + }, + "feature": { + "type": "boolean", + "default": false, + "description": "Flag to indicate if part of a feature schematic." + }, + "reducers": { + "type": "string", + "description": "Specifies the reducers file.", + "aliases": ["r"] + } + }, + "required": [ + "name" + ] +} diff --git a/modules/schematics/src/store/files/__path__/__statePath__/index.ts b/modules/schematics/src/store/files/__path__/__statePath__/index.ts new file mode 100644 index 0000000000..a83f27b499 --- /dev/null +++ b/modules/schematics/src/store/files/__path__/__statePath__/index.ts @@ -0,0 +1,19 @@ +import { + ActionReducer, + ActionReducerMap, + createFeatureSelector, + createSelector, + MetaReducer +} from '@ngrx/store'; +import { environment } from '<%= environmentsPath %>'; + +export interface State { + +} + +export const reducers: ActionReducerMap = { + +}; + + +export const metaReducers: MetaReducer[] = !environment.production ? [] : []; diff --git a/modules/schematics/src/store/index.spec.ts b/modules/schematics/src/store/index.spec.ts new file mode 100644 index 0000000000..5588c20444 --- /dev/null +++ b/modules/schematics/src/store/index.spec.ts @@ -0,0 +1,79 @@ +import { Tree, VirtualTree } from '@angular-devkit/schematics'; +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import * as path from 'path'; +import { createAppModule, getFileContent } from '../utility/test'; +import { Schema as StoreOptions } from './schema'; + +describe('Store Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@ngrx/schematics', + path.join(__dirname, '../../collection.json') + ); + const defaultOptions: StoreOptions = { + name: 'foo', + path: 'app', + sourceDir: 'src', + spec: true, + module: undefined, + flat: false, + root: true, + }; + + let appTree: Tree; + + beforeEach(() => { + appTree = new VirtualTree(); + appTree = createAppModule(appTree); + }); + + it('should create the initial store setup', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('store', options, appTree); + const files = tree.files; + expect(files.indexOf('/src/app/reducers/index.ts')).toBeGreaterThanOrEqual( + 0 + ); + }); + + it('should not be provided by default', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('store', options, appTree); + const content = getFileContent(tree, '/src/app/app.module.ts'); + expect(content).not.toMatch( + /import { reducers, metaReducers } from '\.\/reducers';/ + ); + }); + + it('should import into a specified module', () => { + const options = { ...defaultOptions, module: 'app.module.ts' }; + + const tree = schematicRunner.runSchematic('store', options, appTree); + const content = getFileContent(tree, '/src/app/app.module.ts'); + expect(content).toMatch( + /import { reducers, metaReducers } from '\.\/reducers';/ + ); + }); + + it('should fail if specified module does not exist', () => { + const options = { ...defaultOptions, module: '/src/app/app.moduleXXX.ts' }; + let thrownError: Error | null = null; + try { + schematicRunner.runSchematic('store', options, appTree); + } catch (err) { + thrownError = err; + } + expect(thrownError).toBeDefined(); + }); + + it('should import a feature a specified module', () => { + const options = { ...defaultOptions, root: false, module: 'app.module.ts' }; + + const tree = schematicRunner.runSchematic('store', options, appTree); + const content = getFileContent(tree, '/src/app/app.module.ts'); + expect(content).toMatch( + /StoreModule\.forFeature\('foo', reducers, { metaReducers }\)/ + ); + }); +}); diff --git a/modules/schematics/src/store/index.ts b/modules/schematics/src/store/index.ts new file mode 100644 index 0000000000..a0303b2846 --- /dev/null +++ b/modules/schematics/src/store/index.ts @@ -0,0 +1,155 @@ +import { normalize } from '@angular-devkit/core'; +import { + Rule, + SchematicContext, + SchematicsException, + Tree, + apply, + branchAndMerge, + chain, + filter, + mergeWith, + move, + noop, + template, + url, +} from '@angular-devkit/schematics'; +import 'rxjs/add/operator/merge'; +import * as ts from 'typescript'; +import * as stringUtils from '../strings'; +import { addProviderToModule, addImportToModule } from '../utility/ast-utils'; +import { InsertChange, Change } from '../utility/change'; +import { + buildRelativePath, + findModuleFromOptions, +} from '../utility/find-module'; +import { Schema as ServiceOptions } from './schema'; +import { insertImport } from '../utility/route-utils'; + +function addImportToNgModule(options: ServiceOptions): Rule { + return (host: Tree) => { + if (!options.module) { + return host; + } + + const modulePath = options.module; + if (!host.exists(options.module)) { + throw new Error('Specified module does not exist'); + } + + const text = host.read(modulePath); + if (text === null) { + throw new SchematicsException(`File ${modulePath} does not exist.`); + } + const sourceText = text.toString('utf-8'); + + const source = ts.createSourceFile( + modulePath, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + const statePath = `/${options.sourceDir}/${options.path}/${ + options.statePath + }`; + const relativePath = buildRelativePath(modulePath, statePath); + const environmentsPath = buildRelativePath( + statePath, + `/${options.sourceDir}/environments/environment` + ); + + const storeNgModuleImport = addImportToModule( + source, + modulePath, + options.root + ? `StoreModule.forRoot(reducers, { metaReducers })` + : `StoreModule.forFeature('${stringUtils.camelize( + options.name + )}', reducers, { metaReducers })`, + relativePath + ).shift(); + + let commonImports = [ + insertImport(source, modulePath, 'StoreModule', '@ngrx/store'), + insertImport(source, modulePath, 'reducers, metaReducers', relativePath), + storeNgModuleImport, + ]; + let rootImports: (Change | undefined)[] = []; + + if (options.root) { + const storeDevtoolsNgModuleImport = addImportToModule( + source, + modulePath, + `!environment.production ? StoreDevtoolsModule.instrument() : []`, + relativePath + ).shift(); + + rootImports = rootImports.concat([ + insertImport( + source, + modulePath, + 'StoreDevtoolsModule', + '@ngrx/store-devtools' + ), + insertImport(source, modulePath, 'environment', environmentsPath), + storeDevtoolsNgModuleImport, + ]); + } + + const changes = [...commonImports, ...rootImports]; + const recorder = host.beginUpdate(modulePath); + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(recorder); + + return host; + }; +} + +export default function(options: ServiceOptions): Rule { + options.path = options.path ? normalize(options.path) : options.path; + const sourceDir = options.sourceDir; + const statePath = `/${options.sourceDir}/${options.path}/${ + options.statePath + }/index.ts`; + const environmentsPath = buildRelativePath( + statePath, + `/${options.sourceDir}/environments/environment` + ); + if (!sourceDir) { + throw new SchematicsException(`sourceDir option is required.`); + } + + return (host: Tree, context: SchematicContext) => { + if (options.module) { + options.module = findModuleFromOptions(host, options); + } + + const templateSource = apply(url('./files'), [ + template({ + ...stringUtils, + ...(options as object), + environmentsPath, + }), + move(sourceDir), + ]); + + return chain([ + branchAndMerge( + chain([ + filter( + path => + path.endsWith('.module.ts') && + !path.endsWith('-routing.module.ts') + ), + addImportToNgModule(options), + mergeWith(templateSource), + ]) + ), + ])(host, context); + }; +} diff --git a/modules/schematics/src/store/schema.d.ts b/modules/schematics/src/store/schema.d.ts new file mode 100644 index 0000000000..832110a700 --- /dev/null +++ b/modules/schematics/src/store/schema.d.ts @@ -0,0 +1,20 @@ +export interface Schema { + name: string; + path?: string; + appRoot?: string; + sourceDir?: string; + /** + * Flag to indicate if a dir is created. + */ + flat?: boolean; + /** + * Specifies if a spec file is generated. + */ + spec?: boolean; + /** + * Allows specification of the declaring module. + */ + module?: string; + statePath?: string; + root?: boolean; +} diff --git a/modules/schematics/src/store/schema.json b/modules/schematics/src/store/schema.json new file mode 100644 index 0000000000..3626175526 --- /dev/null +++ b/modules/schematics/src/store/schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsNgRxState", + "title": "NgRx State Management Options Schema", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string", + "default": "app" + }, + "appRoot": { + "type": "string" + }, + "sourceDir": { + "type": "string", + "default": "src" + }, + "flat": { + "type": "boolean", + "default": true, + "description": "Flag to indicate if a dir is created." + }, + "spec": { + "type": "boolean", + "default": true, + "description": "Specifies if a spec file is generated." + }, + "module": { + "type": "string", + "default": "", + "description": "Allows specification of the declaring module.", + "alias": "m", + "subtype": "filepath" + }, + "statePath": { + "type": "string", + "default": "reducers" + }, + "root": { + "type": "boolean", + "default": false, + "description": "Flag to setup the root state or feature state." + } + }, + "required": [ + "name" + ] +} diff --git a/modules/schematics/src/strings.ts b/modules/schematics/src/strings.ts new file mode 100644 index 0000000000..84f4033371 --- /dev/null +++ b/modules/schematics/src/strings.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +const STRING_DASHERIZE_REGEXP = /[ _]/g; +const STRING_DECAMELIZE_REGEXP = /([a-z\d])([A-Z])/g; +const STRING_CAMELIZE_REGEXP = /(-|_|\.|\s)+(.)?/g; +const STRING_UNDERSCORE_REGEXP_1 = /([a-z\d])([A-Z]+)/g; +const STRING_UNDERSCORE_REGEXP_2 = /-|\s+/g; + +/** + * Converts a camelized string into all lower case separated by underscores. + * + ```javascript + decamelize('innerHTML'); // 'inner_html' + decamelize('action_name'); // 'action_name' + decamelize('css-class-name'); // 'css-class-name' + decamelize('my favorite items'); // 'my favorite items' + ``` + + @method decamelize + @param {String} str The string to decamelize. + @return {String} the decamelized string. + */ +export function decamelize(str: string): string { + return str.replace(STRING_DECAMELIZE_REGEXP, '$1_$2').toLowerCase(); +} + +/** + Replaces underscores, spaces, or camelCase with dashes. + + ```javascript + dasherize('innerHTML'); // 'inner-html' + dasherize('action_name'); // 'action-name' + dasherize('css-class-name'); // 'css-class-name' + dasherize('my favorite items'); // 'my-favorite-items' + ``` + + @method dasherize + @param {String} str The string to dasherize. + @return {String} the dasherized string. + */ +export function dasherize(str?: string): string { + return decamelize(str || '').replace(STRING_DASHERIZE_REGEXP, '-'); +} + +/** + Returns the lowerCamelCase form of a string. + + ```javascript + camelize('innerHTML'); // 'innerHTML' + camelize('action_name'); // 'actionName' + camelize('css-class-name'); // 'cssClassName' + camelize('my favorite items'); // 'myFavoriteItems' + camelize('My Favorite Items'); // 'myFavoriteItems' + ``` + + @method camelize + @param {String} str The string to camelize. + @return {String} the camelized string. + */ +export function camelize(str: string): string { + return str + .replace( + STRING_CAMELIZE_REGEXP, + (_match: string, _separator: string, chr: string) => { + return chr ? chr.toUpperCase() : ''; + } + ) + .replace(/^([A-Z])/, (match: string) => match.toLowerCase()); +} + +/** + Returns the UpperCamelCase form of a string. + + ```javascript + 'innerHTML'.classify(); // 'InnerHTML' + 'action_name'.classify(); // 'ActionName' + 'css-class-name'.classify(); // 'CssClassName' + 'my favorite items'.classify(); // 'MyFavoriteItems' + ``` + + @method classify + @param {String} str the string to classify + @return {String} the classified string + */ +export function classify(str: string): string { + return str + .split('.') + .map(part => capitalize(camelize(part))) + .join('.'); +} + +/** + More general than decamelize. Returns the lower\_case\_and\_underscored + form of a string. + + ```javascript + 'innerHTML'.underscore(); // 'inner_html' + 'action_name'.underscore(); // 'action_name' + 'css-class-name'.underscore(); // 'css_class_name' + 'my favorite items'.underscore(); // 'my_favorite_items' + ``` + + @method underscore + @param {String} str The string to underscore. + @return {String} the underscored string. + */ +export function underscore(str: string): string { + return str + .replace(STRING_UNDERSCORE_REGEXP_1, '$1_$2') + .replace(STRING_UNDERSCORE_REGEXP_2, '_') + .toLowerCase(); +} + +/** + Returns the Capitalized form of a string + + ```javascript + 'innerHTML'.capitalize() // 'InnerHTML' + 'action_name'.capitalize() // 'Action_name' + 'css-class-name'.capitalize() // 'Css-class-name' + 'my favorite items'.capitalize() // 'My favorite items' + ``` + + @method capitalize + @param {String} str The string to capitalize. + @return {String} The capitalized string. + */ +export function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.substr(1); +} + +export function uppercase(str: string): string { + return str.toUpperCase(); +} + +export function lowercase(str: string): string { + return str.toLowerCase(); +} diff --git a/modules/schematics/src/utility/ast-utils.ts b/modules/schematics/src/utility/ast-utils.ts new file mode 100644 index 0000000000..c249445242 --- /dev/null +++ b/modules/schematics/src/utility/ast-utils.ts @@ -0,0 +1,494 @@ +/* istanbul ignore file */ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as ts from 'typescript'; +import { Change, InsertChange } from './change'; +import { insertImport } from './route-utils'; + +/** + * Find all nodes from the AST in the subtree of node of SyntaxKind kind. + * @param node + * @param kind + * @param max The maximum number of items to return. + * @return all nodes of kind, or [] if none is found + */ +export function findNodes( + node: ts.Node, + kind: ts.SyntaxKind, + max = Infinity +): ts.Node[] { + if (!node || max == 0) { + return []; + } + + const arr: ts.Node[] = []; + if (node.kind === kind) { + arr.push(node); + max--; + } + if (max > 0) { + for (const child of node.getChildren()) { + findNodes(child, kind, max).forEach(node => { + if (max > 0) { + arr.push(node); + } + max--; + }); + + if (max <= 0) { + break; + } + } + } + + return arr; +} + +/** + * Get all the nodes from a source. + * @param sourceFile The source file object. + * @returns {Observable} An observable of all the nodes in the source. + */ +export function getSourceNodes(sourceFile: ts.SourceFile): ts.Node[] { + const nodes: ts.Node[] = [sourceFile]; + const result = []; + + while (nodes.length > 0) { + const node = nodes.shift(); + + if (node) { + result.push(node); + if (node.getChildCount(sourceFile) >= 0) { + nodes.unshift(...node.getChildren()); + } + } + } + + return result; +} + +/** + * Helper for sorting nodes. + * @return function to sort nodes in increasing order of position in sourceFile + */ +function nodesByPosition(first: ts.Node, second: ts.Node): number { + return first.pos - second.pos; +} + +/** + * Insert `toInsert` after the last occurence of `ts.SyntaxKind[nodes[i].kind]` + * or after the last of occurence of `syntaxKind` if the last occurence is a sub child + * of ts.SyntaxKind[nodes[i].kind] and save the changes in file. + * + * @param nodes insert after the last occurence of nodes + * @param toInsert string to insert + * @param file file to insert changes into + * @param fallbackPos position to insert if toInsert happens to be the first occurence + * @param syntaxKind the ts.SyntaxKind of the subchildren to insert after + * @return Change instance + * @throw Error if toInsert is first occurence but fall back is not set + */ +export function insertAfterLastOccurrence( + nodes: ts.Node[], + toInsert: string, + file: string, + fallbackPos: number, + syntaxKind?: ts.SyntaxKind +): Change { + let lastItem = nodes.sort(nodesByPosition).pop(); + if (!lastItem) { + throw new Error(); + } + if (syntaxKind) { + lastItem = findNodes(lastItem, syntaxKind) + .sort(nodesByPosition) + .pop(); + } + if (!lastItem && fallbackPos == undefined) { + throw new Error( + `tried to insert ${toInsert} as first occurence with no fallback position` + ); + } + const lastItemPosition: number = lastItem ? lastItem.end : fallbackPos; + + return new InsertChange(file, lastItemPosition, toInsert); +} + +export function getContentOfKeyLiteral( + _source: ts.SourceFile, + node: ts.Node +): string | null { + if (node.kind == ts.SyntaxKind.Identifier) { + return (node as ts.Identifier).text; + } else if (node.kind == ts.SyntaxKind.StringLiteral) { + return (node as ts.StringLiteral).text; + } else { + return null; + } +} + +function _angularImportsFromNode( + node: ts.ImportDeclaration, + _sourceFile: ts.SourceFile +): { [name: string]: string } { + const ms = node.moduleSpecifier; + let modulePath: string; + switch (ms.kind) { + case ts.SyntaxKind.StringLiteral: + modulePath = (ms as ts.StringLiteral).text; + break; + default: + return {}; + } + + if (!modulePath.startsWith('@angular/')) { + return {}; + } + + if (node.importClause) { + if (node.importClause.name) { + // This is of the form `import Name from 'path'`. Ignore. + return {}; + } else if (node.importClause.namedBindings) { + const nb = node.importClause.namedBindings; + if (nb.kind == ts.SyntaxKind.NamespaceImport) { + // This is of the form `import * as name from 'path'`. Return `name.`. + return { + [(nb as ts.NamespaceImport).name.text + '.']: modulePath, + }; + } else { + // This is of the form `import {a,b,c} from 'path'` + const namedImports = nb as ts.NamedImports; + + return namedImports.elements + .map( + (is: ts.ImportSpecifier) => + is.propertyName ? is.propertyName.text : is.name.text + ) + .reduce((acc: { [name: string]: string }, curr: string) => { + acc[curr] = modulePath; + + return acc; + }, {}); + } + } + + return {}; + } else { + // This is of the form `import 'path';`. Nothing to do. + return {}; + } +} + +export function getDecoratorMetadata( + source: ts.SourceFile, + identifier: string, + module: string +): ts.Node[] { + const angularImports: { [name: string]: string } = findNodes( + source, + ts.SyntaxKind.ImportDeclaration + ) + .map((node: ts.ImportDeclaration) => _angularImportsFromNode(node, source)) + .reduce( + ( + acc: { [name: string]: string }, + current: { [name: string]: string } + ) => { + for (const key of Object.keys(current)) { + acc[key] = current[key]; + } + + return acc; + }, + {} + ); + + return getSourceNodes(source) + .filter(node => { + return ( + node.kind == ts.SyntaxKind.Decorator && + (node as ts.Decorator).expression.kind == ts.SyntaxKind.CallExpression + ); + }) + .map(node => (node as ts.Decorator).expression as ts.CallExpression) + .filter(expr => { + if (expr.expression.kind == ts.SyntaxKind.Identifier) { + const id = expr.expression as ts.Identifier; + + return ( + id.getFullText(source) == identifier && + angularImports[id.getFullText(source)] === module + ); + } else if ( + expr.expression.kind == ts.SyntaxKind.PropertyAccessExpression + ) { + // This covers foo.NgModule when importing * as foo. + const paExpr = expr.expression as ts.PropertyAccessExpression; + // If the left expression is not an identifier, just give up at that point. + if (paExpr.expression.kind !== ts.SyntaxKind.Identifier) { + return false; + } + + const id = paExpr.name.text; + const moduleId = (paExpr.expression as ts.Identifier).getText(source); + + return id === identifier && angularImports[moduleId + '.'] === module; + } + + return false; + }) + .filter( + expr => + expr.arguments[0] && + expr.arguments[0].kind == ts.SyntaxKind.ObjectLiteralExpression + ) + .map(expr => expr.arguments[0] as ts.ObjectLiteralExpression); +} + +function _addSymbolToNgModuleMetadata( + source: ts.SourceFile, + ngModulePath: string, + metadataField: string, + symbolName: string, + importPath: string +): Change[] { + const nodes = getDecoratorMetadata(source, 'NgModule', '@angular/core'); + let node: any = nodes[0]; // tslint:disable-line:no-any + + // Find the decorator declaration. + if (!node) { + return []; + } + + // Get all the children property assignment of object literals. + const matchingProperties: ts.ObjectLiteralElement[] = (node as ts.ObjectLiteralExpression).properties + .filter(prop => prop.kind == ts.SyntaxKind.PropertyAssignment) + // Filter out every fields that's not "metadataField". Also handles string literals + // (but not expressions). + .filter((prop: ts.PropertyAssignment) => { + const name = prop.name; + switch (name.kind) { + case ts.SyntaxKind.Identifier: + return (name as ts.Identifier).getText(source) == metadataField; + case ts.SyntaxKind.StringLiteral: + return (name as ts.StringLiteral).text == metadataField; + } + + return false; + }); + + // Get the last node of the array literal. + if (!matchingProperties) { + return []; + } + if (matchingProperties.length == 0) { + // We haven't found the field in the metadata declaration. Insert a new field. + const expr = node as ts.ObjectLiteralExpression; + let position: number; + let toInsert: string; + if (expr.properties.length == 0) { + position = expr.getEnd() - 1; + toInsert = ` ${metadataField}: [${symbolName}]\n`; + } else { + node = expr.properties[expr.properties.length - 1]; + position = node.getEnd(); + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + const matches = text.match(/^\r?\n\s*/); + if (matches.length > 0) { + toInsert = `,${matches[0]}${metadataField}: [${symbolName}]`; + } else { + toInsert = `, ${metadataField}: [${symbolName}]`; + } + } + const newMetadataProperty = new InsertChange( + ngModulePath, + position, + toInsert + ); + const newMetadataImport = insertImport( + source, + ngModulePath, + symbolName.replace(/\..*$/, ''), + importPath + ); + + return [newMetadataProperty, newMetadataImport]; + } + + const assignment = matchingProperties[0] as ts.PropertyAssignment; + + // If it's not an array, nothing we can do really. + if (assignment.initializer.kind !== ts.SyntaxKind.ArrayLiteralExpression) { + return []; + } + + const arrLiteral = assignment.initializer as ts.ArrayLiteralExpression; + if (arrLiteral.elements.length == 0) { + // Forward the property. + node = arrLiteral; + } else { + node = arrLiteral.elements; + } + + if (!node) { + console.log( + 'No app module found. Please add your new class to your component.' + ); + + return []; + } + + if (Array.isArray(node)) { + const nodeArray = (node as {}) as Array; + const symbolsArray = nodeArray.map(node => node.getText()); + if (symbolsArray.includes(symbolName)) { + return []; + } + + node = node[node.length - 1]; + } + + let toInsert: string; + let position = node.getEnd(); + if (node.kind == ts.SyntaxKind.ObjectLiteralExpression) { + // We haven't found the field in the metadata declaration. Insert a new + // field. + const expr = node as ts.ObjectLiteralExpression; + if (expr.properties.length == 0) { + position = expr.getEnd() - 1; + toInsert = ` ${metadataField}: [${symbolName}]\n`; + } else { + node = expr.properties[expr.properties.length - 1]; + position = node.getEnd(); + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + if (text.match('^\r?\r?\n')) { + toInsert = `,${text.match(/^\r?\n\s+/)[0]}${metadataField}: [${ + symbolName + }]`; + } else { + toInsert = `, ${metadataField}: [${symbolName}]`; + } + } + } else if (node.kind == ts.SyntaxKind.ArrayLiteralExpression) { + // We found the field but it's empty. Insert it just before the `]`. + position--; + toInsert = `${symbolName}`; + } else { + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + if (text.match(/^\r?\n/)) { + toInsert = `,${text.match(/^\r?\n(\r?)\s+/)[0]}${symbolName}`; + } else { + toInsert = `, ${symbolName}`; + } + } + const insert = new InsertChange(ngModulePath, position, toInsert); + const importInsert: Change = insertImport( + source, + ngModulePath, + symbolName.replace(/\..*$/, ''), + importPath + ); + + return [insert, importInsert]; +} + +/** + * Custom function to insert a declaration (component, pipe, directive) + * into NgModule declarations. It also imports the component. + */ +export function addDeclarationToModule( + source: ts.SourceFile, + modulePath: string, + classifiedName: string, + importPath: string +): Change[] { + return _addSymbolToNgModuleMetadata( + source, + modulePath, + 'declarations', + classifiedName, + importPath + ); +} + +/** + * Custom function to insert a declaration (component, pipe, directive) + * into NgModule declarations. It also imports the component. + */ +export function addImportToModule( + source: ts.SourceFile, + modulePath: string, + classifiedName: string, + importPath: string +): Change[] { + return _addSymbolToNgModuleMetadata( + source, + modulePath, + 'imports', + classifiedName, + importPath + ); +} + +/** + * Custom function to insert a provider into NgModule. It also imports it. + */ +export function addProviderToModule( + source: ts.SourceFile, + modulePath: string, + classifiedName: string, + importPath: string +): Change[] { + return _addSymbolToNgModuleMetadata( + source, + modulePath, + 'providers', + classifiedName, + importPath + ); +} + +/** + * Custom function to insert an export into NgModule. It also imports it. + */ +export function addExportToModule( + source: ts.SourceFile, + modulePath: string, + classifiedName: string, + importPath: string +): Change[] { + return _addSymbolToNgModuleMetadata( + source, + modulePath, + 'exports', + classifiedName, + importPath + ); +} + +/** + * Custom function to insert an export into NgModule. It also imports it. + */ +export function addBootstrapToModule( + source: ts.SourceFile, + modulePath: string, + classifiedName: string, + importPath: string +): Change[] { + return _addSymbolToNgModuleMetadata( + source, + modulePath, + 'bootstrap', + classifiedName, + importPath + ); +} diff --git a/modules/schematics/src/utility/ast-utils_spec.ts b/modules/schematics/src/utility/ast-utils_spec.ts new file mode 100644 index 0000000000..ec4a56c902 --- /dev/null +++ b/modules/schematics/src/utility/ast-utils_spec.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { tags } from '@angular-devkit/core'; +import { VirtualTree } from '@angular-devkit/schematics'; +import * as ts from 'typescript'; +import { Change, InsertChange } from '../utility/change'; +import { getFileContent } from '../utility/test'; +import { addExportToModule } from './ast-utils'; + +function getTsSource(path: string, content: string): ts.SourceFile { + return ts.createSourceFile(path, content, ts.ScriptTarget.Latest, true); +} + +function applyChanges( + path: string, + content: string, + changes: Change[] +): string { + const tree = new VirtualTree(); + tree.create(path, content); + const exportRecorder = tree.beginUpdate(path); + for (const change of changes) { + if (change instanceof InsertChange) { + exportRecorder.insertLeft(change.pos, change.toAdd); + } + } + tree.commitUpdate(exportRecorder); + + return getFileContent(tree, path); +} + +describe('ast utils', () => { + let modulePath: string; + let moduleContent: string; + beforeEach(() => { + modulePath = '/src/app/app.module.ts'; + moduleContent = ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `; + }); + + it('should add export to module', () => { + const source = getTsSource(modulePath, moduleContent); + const changes = addExportToModule( + source, + modulePath, + 'FooComponent', + './foo.component' + ); + const output = applyChanges(modulePath, moduleContent, changes); + expect(output).toMatch(/import { FooComponent } from '.\/foo.component';/); + expect(output).toMatch(/exports: \[FooComponent\]/); + }); + + it('should add export to module if not indented', () => { + moduleContent = tags.stripIndent`${moduleContent}`; + const source = getTsSource(modulePath, moduleContent); + const changes = addExportToModule( + source, + modulePath, + 'FooComponent', + './foo.component' + ); + const output = applyChanges(modulePath, moduleContent, changes); + expect(output).toMatch(/import { FooComponent } from '.\/foo.component';/); + expect(output).toMatch(/exports: \[FooComponent\]/); + }); +}); diff --git a/modules/schematics/src/utility/change.ts b/modules/schematics/src/utility/change.ts new file mode 100644 index 0000000000..6c99a02193 --- /dev/null +++ b/modules/schematics/src/utility/change.ts @@ -0,0 +1,137 @@ +/* istanbul ignore file */ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +export interface Host { + write(path: string, content: string): Promise; + read(path: string): Promise; +} + +export interface Change { + apply(host: Host): Promise; + + // The file this change should be applied to. Some changes might not apply to + // a file (maybe the config). + readonly path: string | null; + + // The order this change should be applied. Normally the position inside the file. + // Changes are applied from the bottom of a file to the top. + readonly order: number; + + // The description of this change. This will be outputted in a dry or verbose run. + readonly description: string; +} + +/** + * An operation that does nothing. + */ +export class NoopChange implements Change { + description = 'No operation.'; + order = Infinity; + path = null; + apply() { + return Promise.resolve(); + } +} + +/** + * Will add text to the source code. + */ +export class InsertChange implements Change { + order: number; + description: string; + + constructor(public path: string, public pos: number, public toAdd: string) { + if (pos < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Inserted ${toAdd} into position ${pos} of ${path}`; + this.order = pos; + } + + /** + * This method does not insert spaces if there is none in the original string. + */ + apply(host: Host) { + return host.read(this.path).then(content => { + const prefix = content.substring(0, this.pos); + const suffix = content.substring(this.pos); + + return host.write(this.path, `${prefix}${this.toAdd}${suffix}`); + }); + } +} + +/** + * Will remove text from the source code. + */ +export class RemoveChange implements Change { + order: number; + description: string; + + constructor( + public path: string, + private pos: number, + private toRemove: string + ) { + if (pos < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Removed ${toRemove} into position ${pos} of ${path}`; + this.order = pos; + } + + apply(host: Host): Promise { + return host.read(this.path).then(content => { + const prefix = content.substring(0, this.pos); + const suffix = content.substring(this.pos + this.toRemove.length); + + // TODO: throw error if toRemove doesn't match removed string. + return host.write(this.path, `${prefix}${suffix}`); + }); + } +} + +/** + * Will replace text from the source code. + */ +export class ReplaceChange implements Change { + order: number; + description: string; + + constructor( + public path: string, + private pos: number, + private oldText: string, + private newText: string + ) { + if (pos < 0) { + throw new Error('Negative positions are invalid'); + } + this.description = `Replaced ${oldText} into position ${pos} of ${ + path + } with ${newText}`; + this.order = pos; + } + + apply(host: Host): Promise { + return host.read(this.path).then(content => { + const prefix = content.substring(0, this.pos); + const suffix = content.substring(this.pos + this.oldText.length); + const text = content.substring(this.pos, this.pos + this.oldText.length); + + if (text !== this.oldText) { + return Promise.reject( + new Error(`Invalid replace: "${text}" != "${this.oldText}".`) + ); + } + + // TODO: throw error if oldText doesn't match removed string. + return host.write(this.path, `${prefix}${this.newText}${suffix}`); + }); + } +} diff --git a/modules/schematics/src/utility/find-module.ts b/modules/schematics/src/utility/find-module.ts new file mode 100644 index 0000000000..254e7caa1a --- /dev/null +++ b/modules/schematics/src/utility/find-module.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { Path, join, normalize, relative } from '@angular-devkit/core'; +import { DirEntry, Tree } from '@angular-devkit/schematics'; +import { dasherize } from '../strings'; + +export interface ModuleOptions { + module?: string; + name?: string; + flat?: boolean; + sourceDir?: string; + path?: string; + skipImport?: boolean; + appRoot?: string; +} + +/** + * Find the module referred by a set of options passed to the schematics. + */ +export function findModuleFromOptions( + host: Tree, + options: ModuleOptions +): Path | undefined { + if (options.hasOwnProperty('skipImport') && options.skipImport) { + return undefined; + } + + if (!options.module) { + const pathToCheck = + (options.sourceDir || '') + + '/' + + (options.path || '') + + (options.flat ? '' : '/' + dasherize(options.name)); + + return normalize(findModule(host, pathToCheck)); + } else { + const modulePath = normalize( + '/' + + options.sourceDir + + '/' + + (options.appRoot || options.path) + + '/' + + options.module + ); + const moduleBaseName = normalize(modulePath) + .split('/') + .pop(); + + if (host.exists(modulePath)) { + return normalize(modulePath); + } else if (host.exists(modulePath + '.ts')) { + return normalize(modulePath + '.ts'); + } else if (host.exists(modulePath + '.module.ts')) { + return normalize(modulePath + '.module.ts'); + } else if (host.exists(modulePath + '/' + moduleBaseName + '.module.ts')) { + return normalize(modulePath + '/' + moduleBaseName + '.module.ts'); + } else { + throw new Error('Specified module does not exist'); + } + } +} + +/** + * Function to find the "closest" module to a generated file's path. + */ +export function findModule(host: Tree, generateDir: string): Path { + let dir: DirEntry | null = host.getDir('/' + generateDir); + + const moduleRe = /\.module\.ts$/; + const routingModuleRe = /-routing\.module\.ts/; + + while (dir) { + const matches = dir.subfiles.filter( + p => moduleRe.test(p) && !routingModuleRe.test(p) + ); + + if (matches.length == 1) { + return join(dir.path, matches[0]); + } else if (matches.length > 1) { + throw new Error( + 'More than one module matches. Use skip-import option to skip importing ' + + 'the component into the closest module.' + ); + } + + dir = dir.parent; + } + + throw new Error( + 'Could not find an NgModule for the new component. Use the skip-import ' + + 'option to skip importing components in NgModule.' + ); +} + +/** + * Build a relative path from one file path to another file path. + */ +export function buildRelativePath(from: string, to: string): string { + from = normalize(from); + to = normalize(to); + + // Convert to arrays. + const fromParts = from.split('/'); + const toParts = to.split('/'); + + // Remove file names (preserving destination) + fromParts.pop(); + const toFileName = toParts.pop(); + + const relativePath = relative( + normalize(fromParts.join('/')), + normalize(toParts.join('/')) + ); + let pathPrefix = ''; + + // Set the path prefix for same dir or child dir, parent dir starts with `..` + if (!relativePath) { + pathPrefix = '.'; + } else if (!relativePath.startsWith('.')) { + pathPrefix = `./`; + } + if (pathPrefix && !pathPrefix.endsWith('/')) { + pathPrefix += '/'; + } + + return pathPrefix + (relativePath ? relativePath + '/' : '') + toFileName; +} diff --git a/modules/schematics/src/utility/find-module_spec.ts b/modules/schematics/src/utility/find-module_spec.ts new file mode 100644 index 0000000000..ecdfe1a3f2 --- /dev/null +++ b/modules/schematics/src/utility/find-module_spec.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { EmptyTree, Tree } from '@angular-devkit/schematics'; +import { findModule } from './find-module'; + +describe('find-module', () => { + describe('findModule', () => { + let host: Tree; + const modulePath = '/foo/src/app/app.module.ts'; + beforeEach(() => { + host = new EmptyTree(); + host.create(modulePath, 'app module'); + }); + + it('should find a module', () => { + const foundModule = findModule(host, 'foo/src/app/bar'); + expect(foundModule).toEqual(modulePath); + }); + + it('should not find a module in another sub dir', () => { + host.create('/foo/src/app/buzz/buzz.module.ts', 'app module'); + const foundModule = findModule(host, 'foo/src/app/bar'); + expect(foundModule).toEqual(modulePath); + }); + + it('should ignore routing modules', () => { + host.create('/foo/src/app/app-routing.module.ts', 'app module'); + const foundModule = findModule(host, 'foo/src/app/bar'); + expect(foundModule).toEqual(modulePath); + }); + + it('should work with weird paths', () => { + host.create('/foo/src/app/app-routing.module.ts', 'app module'); + const foundModule = findModule(host, 'foo//src//app/bar/'); + expect(foundModule).toEqual(modulePath); + }); + + it('should throw if no modules found', () => { + host.create('/foo/src/app/oops.module.ts', 'app module'); + try { + findModule(host, 'foo/src/app/bar'); + throw new Error('Succeeded, should have failed'); + } catch (err) { + expect(err.message).toMatch(/More than one module matches/); + } + }); + + it('should throw if two modules found', () => { + try { + host = new EmptyTree(); + findModule(host, 'foo/src/app/bar'); + throw new Error('Succeeded, should have failed'); + } catch (err) { + expect(err.message).toMatch(/Could not find an NgModule/); + } + }); + }); +}); diff --git a/modules/schematics/src/utility/ngrx-utils.ts b/modules/schematics/src/utility/ngrx-utils.ts new file mode 100644 index 0000000000..2c4bd0e9cb --- /dev/null +++ b/modules/schematics/src/utility/ngrx-utils.ts @@ -0,0 +1,249 @@ +import * as ts from 'typescript'; +import * as stringUtils from '../strings'; +import { InsertChange, Change, NoopChange } from './change'; +import { Tree, SchematicsException, Rule } from '@angular-devkit/schematics'; +import { normalize } from '@angular-devkit/core'; +import { buildRelativePath } from './find-module'; +import { insertImport } from './route-utils'; +import { Schema as ReducerOptions } from '../reducer/schema'; +import { addImportToModule } from './ast-utils'; + +export function addReducerToState(options: ReducerOptions): Rule { + return (host: Tree) => { + if (!options.reducers) { + return host; + } + + const reducersPath = normalize( + `/${options.sourceDir}/${options.path}/${options.reducers}` + ); + + if (!host.exists(reducersPath)) { + throw new Error('Specified reducers path does not exist'); + } + + const text = host.read(reducersPath); + if (text === null) { + throw new SchematicsException(`File ${reducersPath} does not exist.`); + } + + const sourceText = text.toString('utf-8'); + + const source = ts.createSourceFile( + reducersPath, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + const reducerPath = + `/${options.sourceDir}/${options.path}/` + + (options.flat ? '' : stringUtils.dasherize(options.name) + '/') + + stringUtils.dasherize(options.name) + + '.reducer'; + + const relativePath = buildRelativePath(reducersPath, reducerPath); + const reducerImport = insertImport( + source, + reducersPath, + `* as from${stringUtils.classify(options.name)}`, + relativePath, + true + ); + + const stateInferfaceInsert = addReducerToStateInferface( + source, + reducersPath, + options + ); + const reducerMapInsert = addReducerToActionReducerMap( + source, + reducersPath, + options + ); + + const changes = [reducerImport, stateInferfaceInsert, reducerMapInsert]; + const recorder = host.beginUpdate(reducersPath); + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(recorder); + + return host; + }; +} + +/** + * Insert the reducer into the first defined top level interface + */ +export function addReducerToStateInferface( + source: ts.SourceFile, + reducersPath: string, + options: { name: string } +): Change { + const stateInterface = source.statements.find( + stm => stm.kind === ts.SyntaxKind.InterfaceDeclaration + ); + let node = stateInterface as ts.Statement; + + if (!node) { + return new NoopChange(); + } + + const keyInsert = + stringUtils.camelize(options.name) + + ': from' + + stringUtils.classify(options.name) + + '.State;'; + const expr = node as any; + let position; + let toInsert; + + if (expr.members.length === 0) { + position = expr.getEnd() - 1; + toInsert = ` ${keyInsert}\n`; + } else { + node = expr.members[expr.members.length - 1]; + position = node.getEnd() + 1; + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + const matches = text.match(/^\r?\n+(\s*)/); + + if (matches!.length > 0) { + toInsert = `${matches![1]}${keyInsert}\n`; + } else { + toInsert = `\n${keyInsert}`; + } + } + + return new InsertChange(reducersPath, position, toInsert); +} + +/** + * Insert the reducer into the ActionReducerMap + */ +export function addReducerToActionReducerMap( + source: ts.SourceFile, + reducersPath: string, + options: { name: string } +): Change { + let initializer: any; + const actionReducerMap: any = source.statements + .filter(stm => stm.kind === ts.SyntaxKind.VariableStatement) + .filter((stm: any) => !!stm.declarationList) + .map((stm: any) => { + const { + declarations, + }: { + declarations: ts.SyntaxKind.VariableDeclarationList[]; + } = stm.declarationList; + const variable: any = declarations.find( + (decl: any) => decl.kind === ts.SyntaxKind.VariableDeclaration + ); + const type = variable ? variable.type : {}; + + return { initializer: variable.initializer, type }; + }) + .find(({ type }) => type.typeName.text === 'ActionReducerMap'); + + if (!actionReducerMap || !actionReducerMap.initializer) { + return new NoopChange(); + } + + let node = actionReducerMap.initializer; + + const keyInsert = + stringUtils.camelize(options.name) + + ': from' + + stringUtils.classify(options.name) + + '.reducer,'; + const expr = node as any; + let position; + let toInsert; + + if (expr.properties.length === 0) { + position = expr.getEnd() - 1; + toInsert = ` ${keyInsert}\n`; + } else { + node = expr.properties[expr.properties.length - 1]; + position = node.getEnd() + 1; + // Get the indentation of the last element, if any. + const text = node.getFullText(source); + const matches = text.match(/^\r?\n+(\s*)/); + + if (matches.length > 0) { + toInsert = `\n${matches![1]}${keyInsert}`; + } else { + toInsert = `\n${keyInsert}`; + } + } + + return new InsertChange(reducersPath, position, toInsert); +} + +/** + * Add reducer feature to NgModule + */ +export function addReducerImportToNgModule(options: ReducerOptions): Rule { + return (host: Tree) => { + if (!options.module) { + return host; + } + + const modulePath = options.module; + if (!host.exists(options.module)) { + throw new Error('Specified module does not exist'); + } + + const text = host.read(modulePath); + if (text === null) { + throw new SchematicsException(`File ${modulePath} does not exist.`); + } + const sourceText = text.toString('utf-8'); + + const source = ts.createSourceFile( + modulePath, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + const commonImports = [ + insertImport(source, modulePath, 'StoreModule', '@ngrx/store'), + ]; + + const reducerPath = + `/${options.sourceDir}/${options.path}/` + + (options.flat ? '' : stringUtils.dasherize(options.name) + '/') + + stringUtils.dasherize(options.name) + + '.reducer'; + const relativePath = buildRelativePath(modulePath, reducerPath); + const reducerImport = insertImport( + source, + modulePath, + `* as from${stringUtils.classify(options.name)}`, + relativePath, + true + ); + const [storeNgModuleImport] = addImportToModule( + source, + modulePath, + `StoreModule.forFeature('${stringUtils.camelize( + options.name + )}', from${stringUtils.classify(options.name)}.reducer)`, + relativePath + ); + const changes = [...commonImports, reducerImport, storeNgModuleImport]; + const recorder = host.beginUpdate(modulePath); + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(recorder); + + return host; + }; +} diff --git a/modules/schematics/src/utility/route-utils.ts b/modules/schematics/src/utility/route-utils.ts new file mode 100644 index 0000000000..066a0b1129 --- /dev/null +++ b/modules/schematics/src/utility/route-utils.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import * as ts from 'typescript'; +import { findNodes, insertAfterLastOccurrence } from './ast-utils'; +import { Change, NoopChange } from './change'; + +/** + * Add Import `import { symbolName } from fileName` if the import doesn't exit + * already. Assumes fileToEdit can be resolved and accessed. + * @param fileToEdit (file we want to add import to) + * @param symbolName (item to import) + * @param fileName (path to the file) + * @param isDefault (if true, import follows style for importing default exports) + * @return Change + */ + +export function insertImport( + source: ts.SourceFile, + fileToEdit: string, + symbolName: string, + fileName: string, + isDefault = false +): Change { + const rootNode = source; + const allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); + + // get nodes that map to import statements from the file fileName + const relevantImports = allImports.filter(node => { + // StringLiteral of the ImportDeclaration is the import file (fileName in this case). + const importFiles = node + .getChildren() + .filter(child => child.kind === ts.SyntaxKind.StringLiteral) + .map(n => (n as ts.StringLiteral).text); + + return importFiles.filter(file => file === fileName).length === 1; + }); + + if (relevantImports.length > 0) { + let importsAsterisk = false; + // imports from import file + const imports: ts.Node[] = []; + relevantImports.forEach(n => { + Array.prototype.push.apply( + imports, + findNodes(n, ts.SyntaxKind.Identifier) + ); + if (findNodes(n, ts.SyntaxKind.AsteriskToken).length > 0) { + importsAsterisk = true; + } + }); + + // if imports * from fileName, don't add symbolName + if (importsAsterisk) { + return new NoopChange(); + } + + const importTextNodes = imports.filter( + n => (n as ts.Identifier).text === symbolName + ); + + // insert import if it's not there + if (importTextNodes.length === 0) { + const fallbackPos = + findNodes( + relevantImports[0], + ts.SyntaxKind.CloseBraceToken + )[0].getStart() || + findNodes(relevantImports[0], ts.SyntaxKind.FromKeyword)[0].getStart(); + + return insertAfterLastOccurrence( + imports, + `, ${symbolName}`, + fileToEdit, + fallbackPos + ); + } + + return new NoopChange(); + } + + // no such import declaration exists + const useStrict = findNodes(rootNode, ts.SyntaxKind.StringLiteral).filter( + (n: ts.StringLiteral) => n.text === 'use strict' + ); + let fallbackPos = 0; + if (useStrict.length > 0) { + fallbackPos = useStrict[0].end; + } + const open = isDefault ? '' : '{ '; + const close = isDefault ? '' : ' }'; + // if there are no imports or 'use strict' statement, insert import at beginning of file + const insertAtBeginning = allImports.length === 0 && useStrict.length === 0; + const separator = insertAtBeginning ? '' : ';\n'; + const toInsert = + `${separator}import ${open}${symbolName}${close}` + + ` from '${fileName}'${insertAtBeginning ? ';\n' : ''}`; + + return insertAfterLastOccurrence( + allImports, + toInsert, + fileToEdit, + fallbackPos, + ts.SyntaxKind.StringLiteral + ); +} diff --git a/modules/schematics/src/utility/test/create-app-module.ts b/modules/schematics/src/utility/test/create-app-module.ts new file mode 100644 index 0000000000..69b41a6611 --- /dev/null +++ b/modules/schematics/src/utility/test/create-app-module.ts @@ -0,0 +1,26 @@ +import { Tree } from '@angular-devkit/schematics'; + +export function createAppModule(tree: Tree, path?: string): Tree { + tree.create( + path || '/src/app/app.module.ts', + ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + import { AppComponent } from './app.component'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + ` + ); + + return tree; +} diff --git a/modules/schematics/src/utility/test/create-reducers.ts b/modules/schematics/src/utility/test/create-reducers.ts new file mode 100644 index 0000000000..7463ccfbed --- /dev/null +++ b/modules/schematics/src/utility/test/create-reducers.ts @@ -0,0 +1,30 @@ +import { Tree } from '@angular-devkit/schematics'; + +export function createReducers(tree: Tree, path?: string) { + tree.create( + path || '/src/app/reducers/index.ts', + ` + import { + ActionReducer, + ActionReducerMap, + createFeatureSelector, + createSelector, + MetaReducer + } from '@ngrx/store'; + import { environment } from '../../environments/environment'; + + export interface State { + + } + + export const reducers: ActionReducerMap = { + + }; + + + export const metaReducers: MetaReducer[] = !environment.production ? [] : []; + ` + ); + + return tree; +} diff --git a/modules/schematics/src/utility/test/get-file-content.ts b/modules/schematics/src/utility/test/get-file-content.ts new file mode 100644 index 0000000000..44915d283e --- /dev/null +++ b/modules/schematics/src/utility/test/get-file-content.ts @@ -0,0 +1,11 @@ +import { Tree } from '@angular-devkit/schematics'; + +export function getFileContent(tree: Tree, path: string): string { + const fileEntry = tree.get(path); + + if (!fileEntry) { + throw new Error(`The file (${path}) does not exist.`); + } + + return fileEntry.content.toString(); +} diff --git a/modules/schematics/src/utility/test/index.ts b/modules/schematics/src/utility/test/index.ts new file mode 100644 index 0000000000..9b653dcab2 --- /dev/null +++ b/modules/schematics/src/utility/test/index.ts @@ -0,0 +1,2 @@ +export * from './create-app-module'; +export * from './get-file-content'; diff --git a/modules/schematics/tsconfig-build.json b/modules/schematics/tsconfig-build.json new file mode 100644 index 0000000000..cf98575b5c --- /dev/null +++ b/modules/schematics/tsconfig-build.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "declaration": true, + "stripInternal": true, + "experimentalDecorators": true, + "module": "commonjs", + "moduleResolution": "node", + "outDir": "../../dist/packages/schematics", + "paths": { }, + "rootDir": ".", + "sourceMap": true, + "inlineSources": true, + "target": "es5", + "lib": ["es2017", "dom"], + "skipLibCheck": true, + "strict": true + }, + "files": [ + "src/action/index.ts", + "src/container/index.ts", + "src/effect/index.ts", + "src/entity/index.ts", + "src/feature/index.ts", + "src/reducer/index.ts", + "src/store/index.ts" + ], + "exclude": [ + "src/*/files/**/*" + ], + "angularCompilerOptions": { + "skipMetadataEmit": true, + "enableSummariesForJit": false + } +} \ No newline at end of file diff --git a/modules/store-devtools/src/reducer.ts b/modules/store-devtools/src/reducer.ts index 04e454000f..e7fa19af75 100644 --- a/modules/store-devtools/src/reducer.ts +++ b/modules/store-devtools/src/reducer.ts @@ -36,8 +36,8 @@ export interface LiftedState { } /** -* Computes the next entry in the log by applying an action. -*/ + * Computes the next entry in the log by applying an action. + */ function computeNextEntry( reducer: ActionReducer, action: Action, @@ -67,8 +67,8 @@ function computeNextEntry( } /** -* Runs the reducer on invalidated actions to get a fresh computation log. -*/ + * Runs the reducer on invalidated actions to get a fresh computation log. + */ function recomputeStates( computedStates: { state: any; error: any }[], minInvalidatedStateIndex: number, @@ -124,8 +124,8 @@ export function liftInitialState( } /** -* Creates a history state reducer from an app's reducer. -*/ + * Creates a history state reducer from an app's reducer. + */ export function liftReducerWith( initialCommittedState: any, initialLiftedState: LiftedState, @@ -133,8 +133,8 @@ export function liftReducerWith( options: Partial = {} ) { /** - * Manages how the history actions modify the history state. - */ + * Manages how the history actions modify the history state. + */ return ( reducer: ActionReducer ): ActionReducer => (liftedState, liftedAction) => { diff --git a/modules/store-devtools/src/utils.ts b/modules/store-devtools/src/utils.ts index 0862ae0c96..36e8573534 100644 --- a/modules/store-devtools/src/utils.ts +++ b/modules/store-devtools/src/utils.ts @@ -22,8 +22,8 @@ export function unliftAction(liftedState: LiftedState) { } /** -* Lifts an app's action into an action on the lifted store. -*/ + * Lifts an app's action into an action on the lifted store. + */ export function liftAction(action: Action) { return new Actions.PerformAction(action); } diff --git a/modules/store/spec/modules.spec.ts b/modules/store/spec/modules.spec.ts index 13256ce0a6..9176787bd1 100644 --- a/modules/store/spec/modules.spec.ts +++ b/modules/store/spec/modules.spec.ts @@ -48,11 +48,11 @@ describe(`Store Modules`, () => { const rootInitial = { fruit: 'orange' }; beforeEach(() => { - featureAReducerFactory = createSpy( - 'featureAReducerFactory' - ).and.callFake((rm: any, initialState?: any) => { - return (state: any, action: any) => 4; - }); + featureAReducerFactory = createSpy('featureAReducerFactory').and.callFake( + (rm: any, initialState?: any) => { + return (state: any, action: any) => 4; + } + ); rootReducerFactory = createSpy('rootReducerFactory').and.callFake( combineReducers ); diff --git a/package.json b/package.json index f5d50c059e..b9e1a5241c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "precommit": "lint-staged", "bootstrap": "lerna bootstrap", "build": "ts-node ./build/index.ts", - "postbuild": "rimraf dist/**/*.ngsummary.json dist/**/src/*.ngsummary.json", + "postbuild": "rimraf **/dist/**/*.ngsummary.json", "deploy:builds": "ts-node ./build/deploy-build.ts", "test:unit": "node ./tests.js", "test": "nyc yarn run test:unit", @@ -51,7 +51,9 @@ ], "exclude": [ "**/*.spec", - "**/spec/**/*" + "**/spec/**/*", + "/modules/schematics/src/*/files/**/*", + "**/schematics/src/utility/*" ], "include": [ "**/*.ts" @@ -113,7 +115,7 @@ "ngrx-store-freeze": "^0.2.0", "nyc": "^10.1.2", "ora": "^1.3.0", - "prettier": "^1.5.2", + "prettier": "^1.8.2", "protractor": "~5.1.0", "reflect-metadata": "^0.1.9", "rimraf": "^2.5.4", diff --git a/tests.js b/tests.js index 2a1e74ff2c..a8b76ffc74 100644 --- a/tests.js +++ b/tests.js @@ -28,3 +28,4 @@ runner.loadConfig({ }); runner.execute(); + diff --git a/tsconfig.json b/tsconfig.json index b10bbc7884..e8bddd8378 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,7 +36,8 @@ }, "exclude": [ "node_modules", - "**/*/node_modules" + "**/*/node_modules", + "modules/schematics/src/*/files/**/*" ], "compileOnSave": false, "buildOnSave": false, diff --git a/yarn.lock b/yarn.lock index 311fa119fb..af2b24bbd7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6180,9 +6180,9 @@ preserve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" -prettier@^1.5.2: - version "1.5.2" - resolved "https://registry.npmjs.org/prettier/-/prettier-1.5.2.tgz#7ea0751da27b93bfb6cecfcec509994f52d83bb3" +prettier@^1.8.2: + version "1.8.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.8.2.tgz#bff83e7fd573933c607875e5ba3abbdffb96aeb8" pretty-error@^2.0.2: version "2.1.1"