diff --git a/src/cdk/schematics/utils/ast/ng-module-imports.ts b/src/cdk/schematics/utils/ast/ng-module-imports.ts new file mode 100644 index 00000000000..76130343685 --- /dev/null +++ b/src/cdk/schematics/utils/ast/ng-module-imports.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright Google LLC 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 {Tree} from '@angular-devkit/schematics'; +import * as ts from 'typescript'; + +/** + * Whether the Angular module in the given path imports the specifed module class name. + */ +export function hasNgModuleImport(tree: Tree, modulePath: string, className: string): boolean { + const moduleFileContent = tree.read(modulePath); + + if (!moduleFileContent) { + throw new Error(`Could not read Angular module file: ${modulePath}`); + } + + const parsedFile = ts.createSourceFile(modulePath, moduleFileContent.toString(), + ts.ScriptTarget.Latest, true); + let ngModuleMetadata: ts.ObjectLiteralExpression | null = null; + + const findModuleDecorator = (node: ts.Node) => { + if (ts.isDecorator(node) && ts.isCallExpression(node.expression) && + isNgModuleCallExpression(node.expression)) { + ngModuleMetadata = node.expression.arguments[0] as ts.ObjectLiteralExpression; + return; + } + + ts.forEachChild(node, findModuleDecorator); + }; + + ts.forEachChild(parsedFile, findModuleDecorator); + + if (!ngModuleMetadata) { + throw new Error(`Could not find NgModule declaration inside: "${modulePath}"`); + } + + for (let property of ngModuleMetadata!.properties) { + if (!ts.isPropertyAssignment(property) || property.name.getText() !== 'imports' || + !ts.isArrayLiteralExpression(property.initializer)) { + continue; + } + + if (property.initializer.elements.some(element => element.getText() === className)) { + return true; + } + } + + return false; +} + +/** + * Resolves the last identifier that is part of the given expression. This helps resolving + * identifiers of nested property access expressions (e.g. myNamespace.core.NgModule). + */ +function resolveIdentifierOfExpression(expression: ts.Expression): ts.Identifier | null { + if (ts.isIdentifier(expression)) { + return expression; + } else if (ts.isPropertyAccessExpression(expression)) { + return resolveIdentifierOfExpression(expression.expression); + } + return null; +} + +/** Whether the specified call expression is referring to a NgModule definition. */ +function isNgModuleCallExpression(callExpression: ts.CallExpression): boolean { + if (!callExpression.arguments.length || + !ts.isObjectLiteralExpression(callExpression.arguments[0])) { + return false; + } + + const decoratorIdentifier = resolveIdentifierOfExpression(callExpression.expression); + return decoratorIdentifier ? decoratorIdentifier.text === 'NgModule' : false; +} diff --git a/src/cdk/schematics/utils/index.ts b/src/cdk/schematics/utils/index.ts index 2313a73416d..ab3e2b8c868 100644 --- a/src/cdk/schematics/utils/index.ts +++ b/src/cdk/schematics/utils/index.ts @@ -7,6 +7,7 @@ */ export * from './ast'; +export * from './ast/ng-module-imports'; export * from './build-component'; export * from './get-project'; export * from './parse5-element'; diff --git a/src/lib/schematics/ng-add/index.spec.ts b/src/lib/schematics/ng-add/index.spec.ts index f1f55ffbfa7..3e5ec88d697 100644 --- a/src/lib/schematics/ng-add/index.spec.ts +++ b/src/lib/schematics/ng-add/index.spec.ts @@ -3,6 +3,7 @@ import {WorkspaceProject} from '@angular-devkit/core/src/workspace'; import {Tree} from '@angular-devkit/schematics'; import {SchematicTestRunner} from '@angular-devkit/schematics/testing'; import { + addModuleImportToRootModule, createTestApp, getProjectFromWorkspace, getProjectStyleFile, @@ -27,7 +28,19 @@ describe('ng-add schematic', () => { `Expected "${filePath}" to be added to the project styles in the workspace.`); } + /** Removes the specified dependency from the /package.json in the given tree. */ + function removePackageJsonDependency(tree: Tree, dependencyName: string) { + const packageContent = JSON.parse(getFileContent(tree, '/package.json')); + delete packageContent.dependencies[dependencyName]; + tree.overwrite('/package.json', JSON.stringify(packageContent, null, 2)); + } + it('should update package.json', () => { + // By default, the Angular workspace schematic sets up "@angular/animations". In order + // to verify that we would set up the dependency properly if someone doesn't have the + // animations installed already, we remove the animations dependency explicitly. + removePackageJsonDependency(appTree, '@angular/animations'); + const tree = runner.runSchematic('ng-add', {}, appTree); const packageJson = JSON.parse(getFileContent(tree, '/package.json')); const dependencies = packageJson.dependencies; @@ -142,4 +155,56 @@ describe('ng-add schematic', () => { 'Expected the project main file to not contain a HammerJS import.'); }); }); + + describe('animations enabled', () => { + it('should add the BrowserAnimationsModule to the project module', () => { + const tree = runner.runSchematic('ng-add-setup-project', {}, appTree); + const fileContent = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + + expect(fileContent).toContain('BrowserAnimationsModule', + 'Expected the project app module to import the "BrowserAnimationsModule".'); + }); + + it('should not add BrowserAnimationsModule if NoopAnimationsModule is set up', () => { + const workspace = getWorkspace(appTree); + const project = getProjectFromWorkspace(workspace); + + // Simulate the case where a developer uses `ng-add` on an Angular CLI project which already + // explicitly uses the `NoopAnimationsModule`. It would be wrong to forcibly enable browser + // animations without knowing what other components would be affected. In this case, we + // just print a warning message. + addModuleImportToRootModule(appTree, 'NoopAnimationsModule', + '@angular/platform-browser/animations', project); + + spyOn(console, 'warn'); + runner.runSchematic('ng-add-setup-project', {}, appTree); + + expect(console.warn).toHaveBeenCalledWith( + jasmine.stringMatching(/Could not set up "BrowserAnimationsModule"/)); + }); + }); + + describe('animations disabled', () => { + it('should add the NoopAnimationsModule to the project module', () => { + const tree = runner.runSchematic('ng-add-setup-project', {animations: false}, appTree); + const fileContent = getFileContent(tree, '/projects/material/src/app/app.module.ts'); + + expect(fileContent).toContain('NoopAnimationsModule', + 'Expected the project app module to import the "NoopAnimationsModule".'); + }); + + it('should not add NoopAnimationsModule if BrowserAnimationsModule is set up', () => { + const workspace = getWorkspace(appTree); + const project = getProjectFromWorkspace(workspace); + + // Simulate the case where a developer uses `ng-add` on an Angular CLI project which already + // explicitly uses the `BrowserAnimationsModule`. It would be wrong to forcibly change + // to noop animations. + const fileContent = addModuleImportToRootModule(appTree, 'BrowserAnimationsModule', + '@angular/platform-browser/animations', project); + + expect(fileContent).not.toContain('NoopAnimationsModule', + 'Expected the project app module to not import the "NoopAnimationsModule".'); + }); + }); }); diff --git a/src/lib/schematics/ng-add/schema.json b/src/lib/schematics/ng-add/schema.json index d9e1cdef4f2..64ec969d3a9 100644 --- a/src/lib/schematics/ng-add/schema.json +++ b/src/lib/schematics/ng-add/schema.json @@ -22,6 +22,12 @@ "default": true, "description": "Whether gesture support should be set up or not.", "x-prompt": "Set up HammerJS for gesture recognition?" + }, + "animations": { + "type": "boolean", + "default": true, + "description": "Whether Angular browser animations should be set up or not.", + "x-prompt": "Set up browser animations for Angular Material?" } }, "required": [] diff --git a/src/lib/schematics/ng-add/schema.ts b/src/lib/schematics/ng-add/schema.ts index 01e537538b4..992a8e2b521 100644 --- a/src/lib/schematics/ng-add/schema.ts +++ b/src/lib/schematics/ng-add/schema.ts @@ -14,6 +14,9 @@ export interface Schema { /** Whether gesture support should be set up or not. */ gestures: boolean; + /** Whether Angular browser animations should be set up or not. */ + animations: boolean; + /** Name of pre-built theme to install. */ theme: 'indigo-pink' | 'deeppurple-amber' | 'pink-bluegrey' | 'purple-green' | 'custom'; } diff --git a/src/lib/schematics/ng-add/setup-project.ts b/src/lib/schematics/ng-add/setup-project.ts index ce01c28332a..debf536417e 100644 --- a/src/lib/schematics/ng-add/setup-project.ts +++ b/src/lib/schematics/ng-add/setup-project.ts @@ -10,15 +10,25 @@ import {chain, noop, Rule, SchematicsException, Tree} from '@angular-devkit/sche import { addModuleImportToRootModule, getProjectFromWorkspace, + getProjectMainFile, getProjectStyleFile, + hasNgModuleImport, } from '@angular/cdk/schematics'; +import {red, bold} from 'chalk'; import {getWorkspace} from '@schematics/angular/utility/config'; +import {getAppModulePath} from '@schematics/angular/utility/ng-ast-utils'; import * as parse5 from 'parse5'; import {addFontsToIndex} from './fonts/material-fonts'; import {addHammerJsToMain} from './gestures/hammerjs-import'; import {Schema} from './schema'; import {addThemeToAppStyles} from './theming/theming'; +/** Name of the Angular module that enables Angular browser animations. */ +const browserAnimationsModuleName = 'BrowserAnimationsModule'; + +/** Name of the module that switches Angular animations to a noop implementation. */ +const noopAnimationsModuleName = 'NoopAnimationsModule'; + /** * Scaffolds the basics of a Angular Material application, this includes: * - Add Packages to package.json @@ -33,21 +43,43 @@ export default function(options: Schema): Rule { return chain([ options && options.gestures ? addHammerJsToMain(options) : noop(), + addAnimationsModule(options), addThemeToAppStyles(options), - addAnimationRootConfig(options), addFontsToIndex(options), addMaterialAppStyles(options), ]); } -/** Add browser animation module to the app module file. */ -function addAnimationRootConfig(options: Schema) { +/** + * Adds an animation module to the root module of the specified project. In case the "animations" + * option is set to false, we still add the `NoopAnimationsModule` because otherwise various + * components of Angular Material will throw an exception. + */ +function addAnimationsModule(options: Schema) { return (host: Tree) => { const workspace = getWorkspace(host); const project = getProjectFromWorkspace(workspace, options.project); + const appModulePath = getAppModulePath(host, getProjectMainFile(project)); - addModuleImportToRootModule(host, 'BrowserAnimationsModule', - '@angular/platform-browser/animations', project); + if (options.animations) { + // In case the project explicitly uses the NoopAnimationsModule, we should print a warning + // message that makes the user aware of the fact that we won't automatically set up + // animations. If we would add the BrowserAnimationsModule while the NoopAnimationsModule + // is already configured, we would cause unexpected behavior and runtime exceptions. + if (hasNgModuleImport(host, appModulePath, noopAnimationsModuleName)) { + return console.warn(red(`Could not set up "${bold(browserAnimationsModuleName)}" ` + + `because "${bold(noopAnimationsModuleName)}" is already imported. Please manually ` + + `set up browser animations.`)); + } + + addModuleImportToRootModule(host, browserAnimationsModuleName, + '@angular/platform-browser/animations', project); + } else if (!hasNgModuleImport(host, appModulePath, browserAnimationsModuleName)) { + // Do not add the NoopAnimationsModule module if the project already explicitly uses + // the BrowserAnimationsModule. + addModuleImportToRootModule(host, noopAnimationsModuleName, + '@angular/platform-browser/animations', project); + } return host; };