diff --git a/addon/ng2/blueprints/routes/files/__path__/routes.ts b/addon/ng2/blueprints/routes/files/__path__/routes.ts new file mode 100644 index 000000000000..d6d1738de67e --- /dev/null +++ b/addon/ng2/blueprints/routes/files/__path__/routes.ts @@ -0,0 +1 @@ +export default []; diff --git a/addon/ng2/blueprints/routes/index.js b/addon/ng2/blueprints/routes/index.js new file mode 100644 index 000000000000..87d03cd7980d --- /dev/null +++ b/addon/ng2/blueprints/routes/index.js @@ -0,0 +1,67 @@ +const Blueprint = require('ember-cli/lib/models/blueprint'); +const getFiles = Blueprint.prototype.files; +const path = require('path'); +const dynamicPathParser = require('../../utilities/dynamic-path-parser'); +const util = require('../../utilities/route-utils'); +const _ = require('lodash'); +const fs = require('fs'); +const SilentError = require('silent-error'); + +module.exports = { + description: 'Generates a route/guard and template', + + files: function() { + var fileList = getFiles.call(this); + if (this.project && fs.existsSync(path.join(this.project.root, 'src/routes.ts'))) { + return []; + } + return fileList; + }, + + fileMapTokens: function() { + return { + __path__: () => 'src' + }; + }, + + normalizeEntityName: function(entityName) { + var parsedPath = dynamicPathParser(this.project, entityName); + this.dynamicPath = parsedPath; + return entityName; + }, + + afterInstall: function(options) { + const mainFile = path.join(this.project.root, 'src/main.ts'); + const routesFile = path.join(this.project.root, 'src/routes.ts'); + return util.configureMain(mainFile, 'routes', './routes').then(() => { + return this._locals(options); + }).then(names => { + + if (process.env.PWD.indexOf('src/app') === -1) { + return Promise.reject(new SilentError('New route must be within app')); + } + // setup options needed for adding path to routes.ts + var pathOptions = {} + pathOptions.isDefault = options.default; + pathOptions.route = options.path; + pathOptions.component = `${names.classifiedModuleName}Component`; + pathOptions.dasherizedName = names.dasherizedModuleName; + pathOptions = _.merge(pathOptions, this.dynamicPath); + + var newRoutePath = options.taskOptions.args[1]; + if (!newRoutePath) { + throw new SilentError('Please provide new route\'s name'); + } + var file = util.resolveComponentPath(this.project.root, process.env.PWD, newRoutePath); + var component = pathOptions.component; + // confirm that there is an export of the component in componentFile + if (!util.confirmComponentExport(file, component)) { + throw new SilentError(`Please add export for '${component}' to '${file}'`) + } + + pathOptions.component = util.resolveImportName(component, path.join(this.project.root, 'src/routes.ts')); + + return util.addPathToRoutes(routesFile, pathOptions); + }); + } +} diff --git a/addon/ng2/utilities/route-utils.ts b/addon/ng2/utilities/route-utils.ts new file mode 100644 index 000000000000..324910606bf6 --- /dev/null +++ b/addon/ng2/utilities/route-utils.ts @@ -0,0 +1,162 @@ +import * as ts from 'typescript'; +import * as fs from 'fs'; +import {findNodes, insertAfterLastOccurence} from './ast-utils'; + +/** + * Adds provideRouter configuration to the main file (import and bootstrap) if + * main file hasn't been already configured, else it has no effect. + * + * @param (mainFile) path to main.ts in ng project + * @param (routesName) exported name for the routes array from routesFile + * @param (routesFile) + */ +export function configureMain(mainFile: string, routesName: string, routesFile: string): Promise{ + return insertImport(mainFile, 'provideRouter', '@angular/router') + .then(() => { + return insertImport(mainFile, routesName, routesFile, true); + }).then(() => { + let rootNode = ts.createSourceFile(mainFile, fs.readFileSync(mainFile).toString(), + ts.ScriptTarget.ES6, true); + // get ExpressionStatements from the top level syntaxList of the sourceFile + let bootstrapNodes = rootNode.getChildAt(0).getChildren().filter(node => { + // get bootstrap expressions + return node.kind === ts.SyntaxKind.ExpressionStatement && + node.getChildAt(0).getChildAt(0).text.toLowerCase() === 'bootstrap'; + }); + // printAll(bootstrapNodes[0].getChildAt(0).getChildAt(2).getChildAt(2)); + if (bootstrapNodes.length !== 1) { + return Promise.reject(new Error(`Did not bootstrap provideRouter in ${mainFile} because of multiple or no bootstrap calls`)); + } + let bootstrapNode = bootstrapNodes[0].getChildAt(0); + let isBootstraped = findNodes(bootstrapNode, ts.SyntaxKind.Identifier).map(_ => _.text).indexOf('provideRouter') !== -1; + + if (isBootstraped) { + return Promise.resolve(); + } + // if bracket exitst already, add configuration template, + // otherwise, insert into bootstrap parens + var fallBackPos: number, configurePathsTemplate: string, separator: string, syntaxListNodes: any; + let bootstrapProviders = bootstrapNode.getChildAt(2).getChildAt(2); // array of providers + + if ( bootstrapProviders ) { + syntaxListNodes = bootstrapProviders.getChildAt(1).getChildren(); + fallBackPos = bootstrapProviders.getChildAt(2).pos; // closeBracketLiteral + separator = syntaxListNodes.length === 0 ? '' : ', '; + configurePathsTemplate = `provideRouter(${routesName})`; + } else { + fallBackPos = bootstrapNode.getChildAt(3).pos; // closeParenLiteral + syntaxListNodes = bootstrapNode.getChildAt(2).getChildren(); + configurePathsTemplate = `, [provideRouter(${routesName})]`; + separator = ''; + } + + return insertAfterLastOccurence(syntaxListNodes, separator, configurePathsTemplate, + mainFile, fallBackPos); + }); +} + +/** + * Inserts a path to the new route into src/routes.ts if it doesn't exist + * @param routesFile + * @param pathOptions + * @return Promise + * @throws Error if routesFile has multiple export default or none. + */ +export function addPathToRoutes(routesFile: string, pathOptions: {[key: string]: any}): Promise{ + let importPath = pathOptions.dir.replace(pathOptions.appRoot, '') + `/+${pathOptions.dasherizedName}`; + let path: string = pathOptions.path || importPath.replace(/\+/g, ''); + let isDefault = pathOptions.isDefault ? ', terminal: true' : ''; + let content = ` { path: '${path}', component: ${pathOptions.component}${isDefault} }`; + + let rootNode = ts.createSourceFile(routesFile, fs.readFileSync(routesFile).toString(), + ts.ScriptTarget.ES6, true); + let routesNode = rootNode.getChildAt(0).getChildren().filter(n => { + // get export statement + return n.kind === ts.SyntaxKind.ExportAssignment && + n.getFullText().indexOf('export default') !== -1; + }); + if (routesNode.length !== 1){ + return Promise.reject(new Error('Did not insert path in routes.ts because' + + `there were multiple or no 'export default' statements`)); + } + let routesArray = routesNode[0].getChildAt(2).getChildAt(1).getChildren(); // all routes in export route array + let routeExists = routesArray.map(r => r.getFullText()).indexOf(`\n${content}`) !== -1; + if (routeExists){ + // add import in case it hasn't been added already + return insertImport(routesFile, pathOptions.component, `./app${importPath}`); + } + let fallBack = routesNode[0].getChildAt(2).getChildAt(2).pos; // closeBracketLiteral + let separator = routesArray.length > 0 ? ',\n' : '\n'; + content = routesArray.length === 0 ? content + '\n' : content; // expand array before inserting path + return insertAfterLastOccurence(routesArray, separator, content, routesFile, fallBack).then(() => { + return insertImport(routesFile, pathOptions.component, `./app${importPath}`); + }); +} + +/** +* 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) +*/ + +export function insertImport(fileToEdit: string, symbolName: string, + fileName: string, isDefault=false): Promise { + let rootNode = ts.createSourceFile(fileToEdit, fs.readFileSync(fileToEdit).toString(), + ts.ScriptTarget.ES6, true); + let allImports = findNodes(rootNode, ts.SyntaxKind.ImportDeclaration); + + // get nodes that map to import statements from the file fileName + let relevantImports = allImports.filter(node => { + // StringLiteral of the ImportDeclaration is the import file (fileName in this case). + let importFiles = node.getChildren().filter(child => child.kind === ts.SyntaxKind.StringLiteral) + .map(n => (n).text); + return importFiles.filter(file => file === fileName).length === 1; + }); + + if (relevantImports.length > 0) { + + var importsAsterisk: boolean = false; + // imports from import file + let 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 Promise.resolve(); + } + + let importTextNodes = imports.filter(n => (n).text === symbolName); + + // insert import if it's not there + if (importTextNodes.length === 0) { + let fallbackPos = findNodes(relevantImports[0], ts.SyntaxKind.CloseBraceToken)[0].pos || + findNodes(relevantImports[0], ts.SyntaxKind.FromKeyword)[0].pos; + return insertAfterLastOccurence(imports, ', ', symbolName, fileToEdit, fallbackPos); + } + return Promise.resolve(); + } + + // no such import declaration exists + let useStrict = findNodes(rootNode, ts.SyntaxKind.StringLiteral).filter(n => n.text === 'use strict'); + let fallbackPos: number = 0; + if(useStrict.length > 0){ + fallbackPos = useStrict[0].end; + } + let open = isDefault ? '' : '{ '; + let close = isDefault ? '' : ' }'; + // if there are no imports or 'use strict' statement, insert import at beginning of file + let insertAtBeginning = allImports.length === 0 && useStrict.length === 0; + let separator = insertAtBeginning ? '' : ';\n'; + return insertAfterLastOccurence(allImports, separator, `import ${open}${symbolName}${close}` + + ` from '${fileName}'${insertAtBeginning ? ';\n':''}`, + fileToEdit, fallbackPos, ts.SyntaxKind.StringLiteral); +}; + diff --git a/tests/acceptance/route-utils.spec.ts b/tests/acceptance/route-utils.spec.ts new file mode 100644 index 000000000000..f2632877caa0 --- /dev/null +++ b/tests/acceptance/route-utils.spec.ts @@ -0,0 +1,313 @@ +import * as mockFs from 'mock-fs'; +import * as fs from 'fs'; +import { expect } from 'chai'; +import * as nru from '../../addon/ng2/utilities/route-utils'; +import * as ts from 'typescript'; +import { InsertChange, RemoveChange } from '../../addon/ng2/utilities/change'; +import * as Promise from 'ember-cli/lib/ext/promise'; +import * as _ from 'lodash'; + +const readFile = Promise.denodeify(fs.readFile); + +describe('route utils: insertImport', () => { + const sourceFile = 'tmp/tmp.ts'; + beforeEach(() => { + let mockDrive = { + 'tmp': { + 'tmp.ts': '' + } + }; + mockFs(mockDrive); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('inserts as last import if not present', () => { + let content = `'use strict'\n import {foo} from 'bar'\n import * as fz from 'fizz';`; + let editedFile = new InsertChange(sourceFile, 0, content); + return editedFile + .apply() + .then(() => { + return nru.insertImport(sourceFile, 'Router', '@angular/router'); + }).then(() => { + return readFile(sourceFile, 'utf8'); + }).then(newContent => { + expect(newContent).to.equal(content + `\nimport { Router } from '@angular/router';`); + }); + }); + it('does not insert if present', () => { + let content = `'use strict'\n import {Router} from '@angular/router'`; + let editedFile = new InsertChange(sourceFile, 0, content); + return editedFile + .apply() + .then(() => { + return nru.insertImport(sourceFile, 'Router', '@angular/router'); + }).then(() => { + return readFile(sourceFile, 'utf8'); + }).then(newContent => { + expect(newContent).to.equal(content); + }); + }); + it('inserts into existing import clause if import file is already cited', () => { + let content = `'use strict'\n import { foo, bar } from 'fizz'`; + let editedFile = new InsertChange(sourceFile, 0, content); + return editedFile + .apply() + .then(() => { + return nru.insertImport(sourceFile, 'baz', 'fizz'); + }).then(() => { + return readFile(sourceFile, 'utf8'); + }).then(newContent => { + expect(newContent).to.equal(`'use strict'\n import { foo, bar, baz } from 'fizz'`); + }); + }); + it('understands * imports', () => { + let content = `\nimport * as myTest from 'tests' \n`; + let editedFile = new InsertChange(sourceFile, 0, content); + return editedFile + .apply() + .then(() => { + return nru.insertImport(sourceFile, 'Test', 'tests'); + }).then(() => { + return readFile(sourceFile, 'utf8'); + }).then(newContent => { + expect(newContent).to.equal(content); + }); + }); + it('inserts after use-strict', () => { + let content = `'use strict';\n hello`; + let editedFile = new InsertChange(sourceFile, 0, content); + return editedFile + .apply() + .then(() => { + return nru.insertImport(sourceFile, 'Router', '@angular/router'); + }).then(() => { + return readFile(sourceFile, 'utf8'); + }).then(newContent => { + expect(newContent).to. + equal(`'use strict';\nimport { Router } from '@angular/router';\n hello`); + }); + }); +}); + +describe('configureMain', () => { + const mainFile = 'tmp/main.ts'; + const prefix = `import {bootstrap} from '@angular/platform-browser-dynamic'; \n` + + `import { AppComponent } from './app/';\n`; + const routes = 'routes'; + const routesFile = './routes'; + const routerImport = `import { provideRouter } from '@angular/router';\n` + + `import routes from './routes'; \n`; + beforeEach(() => { + let mockDrive = { + 'tmp': { + 'main.ts': `import {bootstrap} from '@angular/platform-browser-dynamic'; \n` + + `import { AppComponent } from './app/'; \n` + + 'bootstrap(AppComponent);' + } + }; + mockFs(mockDrive); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('adds a provideRouter import if not there already', () => { + return nru.configureMain(mainFile, routes, routesFile).then(() => { + return readFile(mainFile, 'utf8'); + }).then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [provideRouter(routes)]);'); + }); + }); + it('does not add a provideRouter import if it exits already', () => { + return nru.insertImport(mainFile, 'provideRouter', '@angular/router') + .then(() => { + return nru.configureMain(mainFile, routes, routesFile); + }).then(() => { + return readFile(mainFile, 'utf8'); + }).then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [provideRouter(routes)]);'); + }); + }); + it('does not duplicate import to route.ts ', () => { + let editedFile = new InsertChange(mainFile, 100, `\nimport routes from './routes';`); + return editedFile + .apply() + .then(() => { + return nru.configureMain(mainFile, routes, routesFile); + }).then(() => { + return readFile(mainFile, 'utf8'); + }).then(content => { + expect(content).to.equal(prefix + `import routes from './routes';\nimport { provideRouter }` + + ` from '@angular/router'; \nbootstrap(AppComponent, [provideRouter(routes)]);`); + }); + }); + it('adds provideRouter to bootstrap if absent and no providers array', () => { + return nru.configureMain(mainFile, routes, routesFile).then(() => { + return readFile(mainFile, 'utf8'); + }).then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [provideRouter(routes)]);'); + }); + }); + it('adds provideRouter to bootstrap if absent and empty providers array', () => { + let editFile = new InsertChange(mainFile, 124, ', []'); + return editFile + .apply() + .then(() => { + return nru.configureMain(mainFile, routes, routesFile); + }).then(() => { + return readFile(mainFile, 'utf8'); + }).then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [provideRouter(routes)]);'); + }); + }); + it('adds provideRouter to bootstrap if absent and non-empty providers array', () => { + let editedFile = new InsertChange(mainFile, 124, ', [ HTTP_PROVIDERS ]'); + return editedFile + .apply() + .then(() => { + return nru.configureMain(mainFile, routes, routesFile); + }).then(() => { + return readFile(mainFile, 'utf8'); + }).then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [ HTTP_PROVIDERS, provideRouter(routes) ]);'); + }); + }); + it('does not add provideRouter to bootstrap if present', () => { + let editedFile = new InsertChange(mainFile, 124, ', [ HTTP_PROVIDERS, provideRouter(routes) ]'); + return editedFile + .apply() + .then(() => { + return nru.configureMain(mainFile, routes, routesFile); + }).then(() => { + return readFile(mainFile, 'utf8'); + }).then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [ HTTP_PROVIDERS, provideRouter(routes) ]);'); + }); + }); + it('inserts into the correct array', () => { + let editedFile = new InsertChange(mainFile, 124, ', [ HTTP_PROVIDERS, {provide: [BAR]}]'); + return editedFile + .apply() + .then(() => { + return nru.configureMain(mainFile, routes, routesFile); + }).then(() => { + return readFile(mainFile, 'utf8'); + }).then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [ HTTP_PROVIDERS, {provide: [BAR]}, provideRouter(routes)]);'); + }); + }); + it('throws an error if there is no or multiple bootstrap expressions', () => { + let editedFile = new InsertChange(mainFile, 126, '\n bootstrap(moreStuff);'); + return editedFile + .apply() + .then(() => { + return nru.configureMain(mainFile, routes, routesFile); + }).catch(e => + expect(e.message).to.equal('Did not bootstrap provideRouter in' + + ' tmp/main.ts because of multiple or no bootstrap calls') + ); + }); + it('configures correctly if bootstrap or provide router is not at top level', () => { + let editedFile = new InsertChange(mainFile, 126, '\n if(e){bootstrap, provideRouter});'); + return editedFile + .apply() + .then(() => { + return nru.configureMain(mainFile, routes, routesFile); + }).then(() => { + return readFile(mainFile, 'utf8'); + }).then(content => { + expect(content).to.equal(prefix + routerImport + + 'bootstrap(AppComponent, [provideRouter(routes)]);\n if(e){bootstrap, provideRouter});'); + }); + }); +}); + +describe('addPathToRoutes', () => { + const routesFile = 'src/routes.ts'; + var options: {[key: string]: string} = {dir: 'src/app', appRoot: 'src/app', + component: 'NewRouteComponent', dasherizedName: 'new-route'}; + + beforeEach(() => { + let mockDrive = { + 'src': { + 'routes.ts' : 'export default [];' + } + }; + mockFs(mockDrive); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('adds import to new route component if absent', () => { + return nru.addPathToRoutes(routesFile, options).then(() => { + return readFile(routesFile, 'utf8'); + }).then(content => { + expect(content).to.equal(`import { NewRouteComponent } from './app/+new-route';\n` + + `export default [\n { path: '/new-route', component: NewRouteComponent }\n];`); + }); + }); + it('adds provided path to export array', () => { + return nru.addPathToRoutes(routesFile, _.merge(options, {path: '/provided/path'})) + .then(() => { + return readFile(routesFile, 'utf8'); + }).then(content => { + expect(content).to.equal(`import { NewRouteComponent } from './app/+new-route';\n` + + `export default [\n { path: '/provided/path', component: NewRouteComponent }\n];`); + delete options['path']; // other tests use same object so leave it as is + }); + }); + it('throws error if multiple export defaults exist', () => { + let editedFile = new InsertChange(routesFile, 20, 'export default {}'); + return editedFile.apply().then(() => { + return nru.addPathToRoutes(routesFile, options); + }).catch(e => { + expect(e.message).to.equal('Did not insert path in routes.ts because' + + `there were multiple or no 'export default' statements`); + }); + }); + it('throws error if no export defaults exists', () => { + let editedFile = new RemoveChange(routesFile, 0, 'export default []'); + return editedFile.apply().then(() => { + return nru.addPathToRoutes(routesFile, options); + }).catch(e => { + expect(e.message).to.equal('Did not insert path in routes.ts because' + + `there were multiple or no 'export default' statements`); + }); + }); + it('adds terminal:true if default flag is set', () => { + return nru.addPathToRoutes(routesFile, _.merge(options, {isDefault: true})) + .then(() => { + return readFile(routesFile, 'utf8'); + }).then(content => { + expect(content).to.equal(`import { NewRouteComponent } from './app/+new-route';\n` + + `export default [\n { path: '/new-route', component: NewRouteComponent, ` + + 'terminal: true }\n];'); + delete options['isDefault']; + }); + }); + it('does not repeat paths', () => { + let editedFile = new InsertChange(routesFile, 16, + `\n { path: '/new-route', component: NewRouteComponent }\n`); + return editedFile.apply().then(() => { + return nru.addPathToRoutes(routesFile, options); + }).then(() => { + return readFile(routesFile, 'utf8'); + }).then(content => { + expect(content).to.equal(`import { NewRouteComponent } from './app/+new-route';\n` + + `export default [\n { path: '/new-route', component: NewRouteComponent }\n];`); + }); + }); +});