feat(core): add undecorated classes with decorated fields schematic (#32130)
Adds a schematic that adds a `Directive` decorator to undecorated classes that have fields that use Angular decorators. PR Close #32130
This commit is contained in:
parent
b6fa9299e5
commit
904a2018e0
|
@ -14,6 +14,7 @@ npm_package(
|
||||||
"//packages/core/schematics/migrations/renderer-to-renderer2",
|
"//packages/core/schematics/migrations/renderer-to-renderer2",
|
||||||
"//packages/core/schematics/migrations/static-queries",
|
"//packages/core/schematics/migrations/static-queries",
|
||||||
"//packages/core/schematics/migrations/template-var-assignment",
|
"//packages/core/schematics/migrations/template-var-assignment",
|
||||||
|
"//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields",
|
||||||
"//packages/core/schematics/migrations/undecorated-classes-with-di",
|
"//packages/core/schematics/migrations/undecorated-classes-with-di",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
@ -24,6 +24,11 @@
|
||||||
"version": "9-beta",
|
"version": "9-beta",
|
||||||
"description": "Decorates undecorated base classes of directives/components that use dependency injection. Copies metadata from base classes to derived directives/components/pipes that are not decorated.",
|
"description": "Decorates undecorated base classes of directives/components that use dependency injection. Copies metadata from base classes to derived directives/components/pipes that are not decorated.",
|
||||||
"factory": "./migrations/undecorated-classes-with-di/index"
|
"factory": "./migrations/undecorated-classes-with-di/index"
|
||||||
|
},
|
||||||
|
"migration-v9-undecorated-classes-with-decorated-fields": {
|
||||||
|
"version": "9-beta",
|
||||||
|
"description": "Adds an Angular decorator to undecorated classes that have decorated fields",
|
||||||
|
"factory": "./migrations/undecorated-classes-with-decorated-fields/index"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ ts_library(
|
||||||
"//packages/core/schematics/migrations/renderer-to-renderer2",
|
"//packages/core/schematics/migrations/renderer-to-renderer2",
|
||||||
"//packages/core/schematics/migrations/static-queries",
|
"//packages/core/schematics/migrations/static-queries",
|
||||||
"//packages/core/schematics/migrations/template-var-assignment",
|
"//packages/core/schematics/migrations/template-var-assignment",
|
||||||
|
"//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields",
|
||||||
"//packages/core/schematics/utils",
|
"//packages/core/schematics/utils",
|
||||||
"//packages/core/schematics/utils/tslint",
|
"//packages/core/schematics/utils/tslint",
|
||||||
"@npm//tslint",
|
"@npm//tslint",
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
/**
|
||||||
|
* @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 {Replacement, RuleFailure, Rules} from 'tslint';
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
|
import {FALLBACK_DECORATOR, addImport, getNamedImports, getUndecoratedClassesWithDecoratedFields, hasNamedImport} from '../undecorated-classes-with-decorated-fields/utils';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TSLint rule that adds an Angular decorator to classes that have Angular field decorators.
|
||||||
|
* https://hackmd.io/vuQfavzfRG6KUCtU7oK_EA
|
||||||
|
*/
|
||||||
|
export class Rule extends Rules.TypedRule {
|
||||||
|
applyWithProgram(sourceFile: ts.SourceFile, program: ts.Program): RuleFailure[] {
|
||||||
|
const typeChecker = program.getTypeChecker();
|
||||||
|
const printer = ts.createPrinter();
|
||||||
|
const classes = getUndecoratedClassesWithDecoratedFields(sourceFile, typeChecker);
|
||||||
|
|
||||||
|
return classes.map((current, index) => {
|
||||||
|
const {classDeclaration: declaration, importDeclaration} = current;
|
||||||
|
const name = declaration.name;
|
||||||
|
|
||||||
|
// Set the class identifier node (if available) as the failing node so IDEs don't highlight
|
||||||
|
// the entire class with red. This is similar to how errors are shown for classes in other
|
||||||
|
// cases like an interface not being implemented correctly.
|
||||||
|
const start = (name || declaration).getStart();
|
||||||
|
const end = (name || declaration).getEnd();
|
||||||
|
const fixes = [Replacement.appendText(declaration.getStart(), `@${FALLBACK_DECORATOR}()\n`)];
|
||||||
|
|
||||||
|
// If it's the first class that we're processing in this file, add `Directive` to the imports.
|
||||||
|
if (index === 0 && !hasNamedImport(importDeclaration, FALLBACK_DECORATOR)) {
|
||||||
|
const namedImports = getNamedImports(importDeclaration);
|
||||||
|
|
||||||
|
if (namedImports) {
|
||||||
|
fixes.push(new Replacement(
|
||||||
|
namedImports.getStart(), namedImports.getWidth(),
|
||||||
|
printer.printNode(
|
||||||
|
ts.EmitHint.Unspecified, addImport(namedImports, FALLBACK_DECORATOR),
|
||||||
|
sourceFile)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RuleFailure(
|
||||||
|
sourceFile, start, end,
|
||||||
|
'Classes with decorated fields must have an Angular decorator as well.',
|
||||||
|
'undecorated-classes-with-decorated-fields', fixes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
load("//tools:defaults.bzl", "ts_library")
|
||||||
|
|
||||||
|
ts_library(
|
||||||
|
name = "undecorated-classes-with-decorated-fields",
|
||||||
|
srcs = glob(["**/*.ts"]),
|
||||||
|
tsconfig = "//packages/core/schematics:tsconfig.json",
|
||||||
|
visibility = [
|
||||||
|
"//packages/core/schematics:__pkg__",
|
||||||
|
"//packages/core/schematics/migrations/google3:__pkg__",
|
||||||
|
"//packages/core/schematics/test:__pkg__",
|
||||||
|
],
|
||||||
|
deps = [
|
||||||
|
"//packages/core/schematics/utils",
|
||||||
|
"@npm//@angular-devkit/schematics",
|
||||||
|
"@npm//@types/node",
|
||||||
|
"@npm//typescript",
|
||||||
|
],
|
||||||
|
)
|
|
@ -0,0 +1,23 @@
|
||||||
|
## Undecorated classes with decorated fields migration
|
||||||
|
|
||||||
|
Automatically adds a `Directive` decorator to undecorated classes that have fields with Angular
|
||||||
|
decorators. Also adds the relevant imports, if necessary.
|
||||||
|
|
||||||
|
#### Before
|
||||||
|
```ts
|
||||||
|
import { Input } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@Input() isActive: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### After
|
||||||
|
```ts
|
||||||
|
import { Input, Directive } from '@angular/core';
|
||||||
|
|
||||||
|
@Directive()
|
||||||
|
export class Base {
|
||||||
|
@Input() isActive: boolean;
|
||||||
|
}
|
||||||
|
```
|
|
@ -0,0 +1,93 @@
|
||||||
|
/**
|
||||||
|
* @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 {Rule, SchematicContext, SchematicsException, Tree} from '@angular-devkit/schematics';
|
||||||
|
import {dirname, relative} from 'path';
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
|
import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths';
|
||||||
|
import {parseTsconfigFile} from '../../utils/typescript/parse_tsconfig';
|
||||||
|
import {FALLBACK_DECORATOR, addImport, getNamedImports, getUndecoratedClassesWithDecoratedFields, hasNamedImport} from './utils';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration that adds an Angular decorator to classes that have Angular field decorators.
|
||||||
|
* https://hackmd.io/vuQfavzfRG6KUCtU7oK_EA
|
||||||
|
*/
|
||||||
|
export default function(): Rule {
|
||||||
|
return (tree: Tree, context: SchematicContext) => {
|
||||||
|
const {buildPaths, testPaths} = getProjectTsConfigPaths(tree);
|
||||||
|
const basePath = process.cwd();
|
||||||
|
const allPaths = [...buildPaths, ...testPaths];
|
||||||
|
const logger = context.logger;
|
||||||
|
|
||||||
|
logger.info('------ Undecorated classes with decorated fields migration ------');
|
||||||
|
logger.info(
|
||||||
|
'As of Angular 9, it is no longer supported to have Angular field ' +
|
||||||
|
'decorators on a class that does not have an Angular decorator.');
|
||||||
|
|
||||||
|
if (!allPaths.length) {
|
||||||
|
throw new SchematicsException(
|
||||||
|
'Could not find any tsconfig file. Cannot add an Angular decorator to undecorated classes.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tsconfigPath of allPaths) {
|
||||||
|
runUndecoratedClassesMigration(tree, tsconfigPath, basePath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runUndecoratedClassesMigration(tree: Tree, tsconfigPath: string, basePath: string) {
|
||||||
|
const parsed = parseTsconfigFile(tsconfigPath, dirname(tsconfigPath));
|
||||||
|
const host = ts.createCompilerHost(parsed.options, true);
|
||||||
|
|
||||||
|
// We need to overwrite the host "readFile" method, as we want the TypeScript
|
||||||
|
// program to be based on the file contents in the virtual file tree. Otherwise
|
||||||
|
// if we run the migration for multiple tsconfig files which have intersecting
|
||||||
|
// source files, it can end up updating them multiple times.
|
||||||
|
host.readFile = fileName => {
|
||||||
|
const buffer = tree.read(relative(basePath, fileName));
|
||||||
|
// Strip BOM as otherwise TSC methods (Ex: getWidth) will return an offset which
|
||||||
|
// which breaks the CLI UpdateRecorder.
|
||||||
|
// See: https://github.com/angular/angular/pull/30719
|
||||||
|
return buffer ? buffer.toString().replace(/^\uFEFF/, '') : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const program = ts.createProgram(parsed.fileNames, parsed.options, host);
|
||||||
|
const typeChecker = program.getTypeChecker();
|
||||||
|
const printer = ts.createPrinter();
|
||||||
|
const sourceFiles = program.getSourceFiles().filter(
|
||||||
|
file => !file.isDeclarationFile && !program.isSourceFileFromExternalLibrary(file));
|
||||||
|
|
||||||
|
sourceFiles.forEach(sourceFile => {
|
||||||
|
const update = tree.beginUpdate(relative(basePath, sourceFile.fileName));
|
||||||
|
const classes = getUndecoratedClassesWithDecoratedFields(sourceFile, typeChecker);
|
||||||
|
|
||||||
|
classes.forEach((current, index) => {
|
||||||
|
// If it's the first class that we're processing in this file, add `Directive` to the imports.
|
||||||
|
if (index === 0 && !hasNamedImport(current.importDeclaration, FALLBACK_DECORATOR)) {
|
||||||
|
const namedImports = getNamedImports(current.importDeclaration);
|
||||||
|
|
||||||
|
if (namedImports) {
|
||||||
|
update.remove(namedImports.getStart(), namedImports.getWidth());
|
||||||
|
update.insertRight(
|
||||||
|
namedImports.getStart(),
|
||||||
|
printer.printNode(
|
||||||
|
ts.EmitHint.Unspecified, addImport(namedImports, FALLBACK_DECORATOR),
|
||||||
|
sourceFile));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We don't need to go through the AST to insert the decorator, because the change
|
||||||
|
// is pretty basic. Also this has a better chance of preserving the user's formatting.
|
||||||
|
update.insertLeft(current.classDeclaration.getStart(), `@${FALLBACK_DECORATOR}()\n`);
|
||||||
|
});
|
||||||
|
|
||||||
|
tree.commitUpdate(update);
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
/**
|
||||||
|
* @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 {getAngularDecorators} from '../../utils/ng_decorators';
|
||||||
|
|
||||||
|
/** Name of the decorator that should be added to undecorated classes. */
|
||||||
|
export const FALLBACK_DECORATOR = 'Directive';
|
||||||
|
|
||||||
|
/** Finds all of the undecorated classes that have decorated fields within a file. */
|
||||||
|
export function getUndecoratedClassesWithDecoratedFields(
|
||||||
|
sourceFile: ts.SourceFile, typeChecker: ts.TypeChecker) {
|
||||||
|
const classes: UndecoratedClassWithDecoratedFields[] = [];
|
||||||
|
|
||||||
|
sourceFile.forEachChild(function walk(node: ts.Node) {
|
||||||
|
if (ts.isClassDeclaration(node) &&
|
||||||
|
(!node.decorators || !getAngularDecorators(typeChecker, node.decorators).length)) {
|
||||||
|
for (const member of node.members) {
|
||||||
|
const angularDecorators =
|
||||||
|
member.decorators && getAngularDecorators(typeChecker, member.decorators);
|
||||||
|
|
||||||
|
if (angularDecorators && angularDecorators.length) {
|
||||||
|
classes.push(
|
||||||
|
{classDeclaration: node, importDeclaration: angularDecorators[0].importNode});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.forEachChild(walk);
|
||||||
|
});
|
||||||
|
|
||||||
|
return classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks whether an import declaration has an import with a certain name. */
|
||||||
|
export function hasNamedImport(declaration: ts.ImportDeclaration, symbolName: string): boolean {
|
||||||
|
const namedImports = getNamedImports(declaration);
|
||||||
|
|
||||||
|
if (namedImports) {
|
||||||
|
return namedImports.elements.some(element => {
|
||||||
|
const {name, propertyName} = element;
|
||||||
|
return propertyName ? propertyName.text === symbolName : name.text === symbolName;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts the NamedImports node from an import declaration. */
|
||||||
|
export function getNamedImports(declaration: ts.ImportDeclaration): ts.NamedImports|null {
|
||||||
|
const namedBindings = declaration.importClause && declaration.importClause.namedBindings;
|
||||||
|
return (namedBindings && ts.isNamedImports(namedBindings)) ? namedBindings : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a new import to a NamedImports node. */
|
||||||
|
export function addImport(declaration: ts.NamedImports, symbolName: string) {
|
||||||
|
return ts.updateNamedImports(declaration, [
|
||||||
|
...declaration.elements, ts.createImportSpecifier(undefined, ts.createIdentifier(symbolName))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UndecoratedClassWithDecoratedFields {
|
||||||
|
classDeclaration: ts.ClassDeclaration;
|
||||||
|
importDeclaration: ts.ImportDeclaration;
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ ts_library(
|
||||||
"//packages/core/schematics/migrations/renderer-to-renderer2",
|
"//packages/core/schematics/migrations/renderer-to-renderer2",
|
||||||
"//packages/core/schematics/migrations/static-queries",
|
"//packages/core/schematics/migrations/static-queries",
|
||||||
"//packages/core/schematics/migrations/template-var-assignment",
|
"//packages/core/schematics/migrations/template-var-assignment",
|
||||||
|
"//packages/core/schematics/migrations/undecorated-classes-with-decorated-fields",
|
||||||
"//packages/core/schematics/migrations/undecorated-classes-with-di",
|
"//packages/core/schematics/migrations/undecorated-classes-with-di",
|
||||||
"//packages/core/schematics/utils",
|
"//packages/core/schematics/utils",
|
||||||
"@npm//@angular-devkit/core",
|
"@npm//@angular-devkit/core",
|
||||||
|
|
|
@ -0,0 +1,233 @@
|
||||||
|
/**
|
||||||
|
* @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 {readFileSync, writeFileSync} from 'fs';
|
||||||
|
import {dirname, join} from 'path';
|
||||||
|
import * as shx from 'shelljs';
|
||||||
|
import {Configuration, Linter} from 'tslint';
|
||||||
|
|
||||||
|
describe('Google3 undecorated classes with decorated fields TSLint rule', () => {
|
||||||
|
const rulesDirectory = dirname(
|
||||||
|
require.resolve('../../migrations/google3/undecoratedClassesWithDecoratedFieldsRule'));
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = join(process.env['TEST_TMPDIR'] !, 'google3-test');
|
||||||
|
shx.mkdir('-p', tmpDir);
|
||||||
|
writeFile('tsconfig.json', JSON.stringify({compilerOptions: {module: 'es2015'}}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => shx.rm('-r', tmpDir));
|
||||||
|
|
||||||
|
function runTSLint(fix: boolean) {
|
||||||
|
const program = Linter.createProgram(join(tmpDir, 'tsconfig.json'));
|
||||||
|
const linter = new Linter({fix, rulesDirectory: [rulesDirectory]}, program);
|
||||||
|
const config = Configuration.parseConfigFile({
|
||||||
|
rules: {'undecorated-classes-with-decorated-fields': true},
|
||||||
|
linterOptions: {typeCheck: true}
|
||||||
|
});
|
||||||
|
|
||||||
|
program.getRootFileNames().forEach(fileName => {
|
||||||
|
linter.lint(fileName, program.getSourceFile(fileName) !.getFullText(), config);
|
||||||
|
});
|
||||||
|
|
||||||
|
return linter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFile(fileName: string, content: string) {
|
||||||
|
writeFileSync(join(tmpDir, fileName), content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFile(fileName: string) { return readFileSync(join(tmpDir, fileName), 'utf8'); }
|
||||||
|
|
||||||
|
it('should flag undecorated classes with decorated fields', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { Input, Directive } from '@angular/core';
|
||||||
|
|
||||||
|
@Directive()
|
||||||
|
export class ValidClass {
|
||||||
|
@Input() isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidClass {
|
||||||
|
@Input() isActive: boolean;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
const linter = runTSLint(false);
|
||||||
|
const failures = linter.getResult().failures.map(failure => failure.getFailure());
|
||||||
|
|
||||||
|
expect(failures.length).toBe(1);
|
||||||
|
expect(failures[0])
|
||||||
|
.toBe('Classes with decorated fields must have an Angular decorator as well.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should add an import for Directive if there isn't one already`, () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { Input } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@Input() isActive: boolean;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
runTSLint(true);
|
||||||
|
expect(getFile('/index.ts')).toContain(`import { Input, Directive } from '@angular/core';`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not change the imports if there is an import for Directive already', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { Directive, Input } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@Input() isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Directive()
|
||||||
|
export class Child extends Base {
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
runTSLint(true);
|
||||||
|
expect(getFile('/index.ts')).toContain(`import { Directive, Input } from '@angular/core';`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have @Input', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { Input } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@Input() isActive: boolean;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
runTSLint(true);
|
||||||
|
expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not change decorated classes', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { Input, Component, Output, EventEmitter } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({})
|
||||||
|
export class Base {
|
||||||
|
@Input() isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Child extends Base {
|
||||||
|
@Output() clicked = new EventEmitter<void>();
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
runTSLint(true);
|
||||||
|
const content = getFile('/index.ts');
|
||||||
|
expect(content).toContain(
|
||||||
|
`import { Input, Component, Output, EventEmitter, Directive } from '@angular/core';`);
|
||||||
|
expect(content).toContain(`@Component({})\n export class Base {`);
|
||||||
|
expect(content).toContain(`@Directive()\nexport class Child extends Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have @Output', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { Output, EventEmitter } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@Output() clicked = new EventEmitter<void>();
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
runTSLint(true);
|
||||||
|
expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have a host binding', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { HostBinding } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@HostBinding('attr.id')
|
||||||
|
get id() {
|
||||||
|
return 'id-' + Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
runTSLint(true);
|
||||||
|
expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have a host listener', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { HostListener } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@HostListener('keydown')
|
||||||
|
handleKeydown() {
|
||||||
|
console.log('Key has been pressed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
runTSLint(true);
|
||||||
|
expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have a ViewChild query', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { ViewChild, ElementRef } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@ViewChild('button', { static: false }) button: ElementRef<HTMLElement>;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
runTSLint(true);
|
||||||
|
expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have a ViewChildren query', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { ViewChildren, ElementRef } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@ViewChildren('button') button: ElementRef<HTMLElement>;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
runTSLint(true);
|
||||||
|
expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have a ContentChild query', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { ContentChild, ElementRef } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@ContentChild('button', { static: false }) button: ElementRef<HTMLElement>;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
runTSLint(true);
|
||||||
|
expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have a ContentChildren query', () => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { ContentChildren, ElementRef } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@ContentChildren('button') button: ElementRef<HTMLElement>;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
runTSLint(true);
|
||||||
|
expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
|
@ -0,0 +1,218 @@
|
||||||
|
/**
|
||||||
|
* @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 {getSystemPath, normalize, virtualFs} from '@angular-devkit/core';
|
||||||
|
import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing';
|
||||||
|
import {HostTree} from '@angular-devkit/schematics';
|
||||||
|
import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing';
|
||||||
|
import * as shx from 'shelljs';
|
||||||
|
|
||||||
|
describe('Undecorated classes with decorated fields migration', () => {
|
||||||
|
let runner: SchematicTestRunner;
|
||||||
|
let host: TempScopedNodeJsSyncHost;
|
||||||
|
let tree: UnitTestTree;
|
||||||
|
let tmpDirPath: string;
|
||||||
|
let previousWorkingDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
runner = new SchematicTestRunner('test', require.resolve('../migrations.json'));
|
||||||
|
host = new TempScopedNodeJsSyncHost();
|
||||||
|
tree = new UnitTestTree(new HostTree(host));
|
||||||
|
|
||||||
|
writeFile('/tsconfig.json', JSON.stringify({compilerOptions: {lib: ['es2015']}}));
|
||||||
|
writeFile('/angular.json', JSON.stringify({
|
||||||
|
projects: {t: {architect: {build: {options: {tsConfig: './tsconfig.json'}}}}}
|
||||||
|
}));
|
||||||
|
|
||||||
|
previousWorkingDir = shx.pwd();
|
||||||
|
tmpDirPath = getSystemPath(host.root);
|
||||||
|
|
||||||
|
// Switch into the temporary directory path. This allows us to run
|
||||||
|
// the schematic against our custom unit test tree.
|
||||||
|
shx.cd(tmpDirPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
shx.cd(previousWorkingDir);
|
||||||
|
shx.rm('-r', tmpDirPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`should add an import for Directive if there isn't one already`, async() => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { Input } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@Input() isActive: boolean;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
await runMigration();
|
||||||
|
expect(tree.readContent('/index.ts'))
|
||||||
|
.toContain(`import { Input, Directive } from '@angular/core';`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not change the imports if there is an import for Directive already', async() => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { Directive, Input } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@Input() isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Directive()
|
||||||
|
export class Child extends Base {
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
await runMigration();
|
||||||
|
expect(tree.readContent('/index.ts'))
|
||||||
|
.toContain(`import { Directive, Input } from '@angular/core';`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have @Input', async() => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { Input } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@Input() isActive: boolean;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
await runMigration();
|
||||||
|
expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not change decorated classes', async() => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { Input, Component, Output, EventEmitter } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({})
|
||||||
|
export class Base {
|
||||||
|
@Input() isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Child extends Base {
|
||||||
|
@Output() clicked = new EventEmitter<void>();
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
await runMigration();
|
||||||
|
const content = tree.readContent('/index.ts');
|
||||||
|
expect(content).toContain(
|
||||||
|
`import { Input, Component, Output, EventEmitter, Directive } from '@angular/core';`);
|
||||||
|
expect(content).toContain(`@Component({})\n export class Base {`);
|
||||||
|
expect(content).toContain(`@Directive()\nexport class Child extends Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have @Output', async() => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { Output, EventEmitter } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@Output() clicked = new EventEmitter<void>();
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
await runMigration();
|
||||||
|
expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have a host binding', async() => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { HostBinding } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@HostBinding('attr.id')
|
||||||
|
get id() {
|
||||||
|
return 'id-' + Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
await runMigration();
|
||||||
|
expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have a host listener', async() => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { HostListener } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@HostListener('keydown')
|
||||||
|
handleKeydown() {
|
||||||
|
console.log('Key has been pressed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
await runMigration();
|
||||||
|
expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have a ViewChild query', async() => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { ViewChild, ElementRef } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@ViewChild('button', { static: false }) button: ElementRef<HTMLElement>;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
await runMigration();
|
||||||
|
expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have a ViewChildren query', async() => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { ViewChildren, ElementRef } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@ViewChildren('button') button: ElementRef<HTMLElement>;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
await runMigration();
|
||||||
|
expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have a ContentChild query', async() => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { ContentChild, ElementRef } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@ContentChild('button', { static: false }) button: ElementRef<HTMLElement>;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
await runMigration();
|
||||||
|
expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add @Directive to undecorated classes that have a ContentChildren query', async() => {
|
||||||
|
writeFile('/index.ts', `
|
||||||
|
import { ContentChildren, ElementRef } from '@angular/core';
|
||||||
|
|
||||||
|
export class Base {
|
||||||
|
@ContentChildren('button') button: ElementRef<HTMLElement>;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
|
await runMigration();
|
||||||
|
expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`);
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeFile(filePath: string, contents: string) {
|
||||||
|
host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMigration() {
|
||||||
|
return runner
|
||||||
|
.runSchematicAsync('migration-v9-undecorated-classes-with-decorated-fields', {}, tree)
|
||||||
|
.toPromise();
|
||||||
|
}
|
||||||
|
});
|
Loading…
Reference in New Issue