fix(core): undecorated-classes-with-decorated-fields migration does not decorate derived classes (#35339)

The `undecorated-classes-with-decorated-fields` migration has been
introduced with 904a2018e0, but misses
logic for decorating derived classes of undecorated classes which use
Angular features. Example scenario:

```ts
export abstract class MyBaseClass {
  @Input() someInput = true;
}

export abstract class BaseClassTwo extends MyBaseClass {}

@Component(...)
export class MyButton extends BaseClassTwo {}
```

Both abstract classes would need to be migrated. Previously, the migration
only added `@Directive()` to `MyBaseClass`, but with this change, it
also decorates `BaseClassTwo`.

This is necessary because the Angular Compiler requires `BaseClassTwo` to
have a directive definition when it flattens the directive metadata for
`MyButton` in order to perform type checking. Technically, not decorating
`BaseClassTwo` does not break at runtime.

We basically want to enforce consistent use of `@Directive` to simplify the
mental model. [See the migration guide](https://angular.io/guide/migration-undecorated-classes#migrating-classes-that-use-field-decorators).

Fixes #34376.

PR Close #35339
This commit is contained in:
Paul Gschwendtner
2020-02-11 16:33:20 +01:00
committed by Kara Erickson
parent 2366480250
commit 32eafef6a7
11 changed files with 358 additions and 138 deletions

View File

@ -64,7 +64,7 @@ describe('Google3 undecorated classes with decorated fields TSLint rule', () =>
expect(failures.length).toBe(1);
expect(failures[0])
.toBe('Classes with decorated fields must have an Angular decorator as well.');
.toBe('Class needs to be decorated with "@Directive()" because it uses Angular features.');
});
it(`should add an import for Directive if there isn't one already`, () => {
@ -97,6 +97,27 @@ describe('Google3 undecorated classes with decorated fields TSLint rule', () =>
expect(getFile('/index.ts')).toContain(`import { Directive, Input } from '@angular/core';`);
});
it('should not generate conflicting imports there is a different `Directive` symbol', async() => {
writeFile('/index.ts', `
import { HostBinding } from '@angular/core';
export class Directive {
// Simulates a scenario where a library defines a class named "Directive".
// We don't want to generate a conflicting import.
}
export class MyLibrarySharedBaseClass {
@HostBinding('class.active') isActive: boolean;
}
`);
runTSLint(true);
const fileContent = getFile('/index.ts');
expect(fileContent)
.toContain(`import { HostBinding, Directive as Directive_1 } from '@angular/core';`);
expect(fileContent).toMatch(/@Directive_1\(\)\s+export class MyLibrarySharedBaseClass/);
});
it('should add @Directive to undecorated classes that have @Input', () => {
writeFile('/index.ts', `
import { Input } from '@angular/core';
@ -229,4 +250,35 @@ describe('Google3 undecorated classes with decorated fields TSLint rule', () =>
expect(getFile('/index.ts')).toContain(`@Directive()\nexport class Base {`);
});
it('should add @Directive to undecorated derived classes of a migrated class', async() => {
writeFile('/index.ts', `
import { Input, Directive, NgModule } from '@angular/core';
export class Base {
@Input() isActive: boolean;
}
export class DerivedA extends Base {}
export class DerivedB extends DerivedA {}
export class DerivedC extends DerivedB {}
@Directive({selector: 'my-comp'})
export class MyComp extends DerivedC {}
export class MyCompWrapped extends MyComp {}
@NgModule({declarations: [MyComp, MyCompWrapped]})
export class AppModule {}
`);
runTSLint(true);
const fileContent = getFile('/index.ts');
expect(fileContent).toContain(`import { Input, Directive, NgModule } from '@angular/core';`);
expect(fileContent).toMatch(/@Directive\(\)\s+export class Base/);
expect(fileContent).toMatch(/@Directive\(\)\s+export class DerivedA/);
expect(fileContent).toMatch(/@Directive\(\)\s+export class DerivedB/);
expect(fileContent).toMatch(/@Directive\(\)\s+export class DerivedC/);
expect(fileContent).toMatch(/}\s+@Directive\(\{selector: 'my-comp'}\)\s+export class MyComp/);
expect(fileContent).toMatch(/}\s+export class MyCompWrapped/);
});
});

View File

@ -74,6 +74,27 @@ describe('Undecorated classes with decorated fields migration', () => {
.toContain(`import { Directive, Input } from '@angular/core';`);
});
it('should not generate conflicting imports there is a different `Directive` symbol', async() => {
writeFile('/index.ts', `
import { HostBinding } from '@angular/core';
export class Directive {
// Simulates a scenario where a library defines a class named "Directive".
// We don't want to generate a conflicting import.
}
export class MyLibrarySharedBaseClass {
@HostBinding('class.active') isActive: boolean;
}
`);
await runMigration();
const fileContent = tree.readContent('/index.ts');
expect(fileContent)
.toContain(`import { HostBinding, Directive as Directive_1 } from '@angular/core';`);
expect(fileContent).toMatch(/@Directive_1\(\)\s+export class MyLibrarySharedBaseClass/);
});
it('should add @Directive to undecorated classes that have @Input', async() => {
writeFile('/index.ts', `
import { Input } from '@angular/core';
@ -206,6 +227,38 @@ describe('Undecorated classes with decorated fields migration', () => {
expect(tree.readContent('/index.ts')).toContain(`@Directive()\nexport class Base {`);
});
it('should add @Directive to undecorated derived classes of a migrated class', async() => {
writeFile('/index.ts', `
import { Input, Directive, NgModule } from '@angular/core';
export class Base {
@Input() isActive: boolean;
}
export class DerivedA extends Base {}
export class DerivedB extends DerivedA {}
export class DerivedC extends DerivedB {}
@Directive({selector: 'my-comp'})
export class MyComp extends DerivedC {}
export class MyCompWrapped extends MyComp {}
@NgModule({declarations: [MyComp, MyCompWrapped]})
export class AppModule {}
`);
await runMigration();
const fileContent = tree.readContent('/index.ts');
expect(fileContent).toContain(`import { Input, Directive, NgModule } from '@angular/core';`);
expect(fileContent).toMatch(/@Directive\(\)\s+export class Base/);
expect(fileContent).toMatch(/@Directive\(\)\s+export class DerivedA/);
expect(fileContent).toMatch(/@Directive\(\)\s+export class DerivedB/);
expect(fileContent).toMatch(/@Directive\(\)\s+export class DerivedC/);
expect(fileContent).toMatch(/}\s+@Directive\(\{selector: 'my-comp'}\)\s+export class MyComp/);
expect(fileContent).toMatch(/}\s+export class MyCompWrapped/);
});
function writeFile(filePath: string, contents: string) {
host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents));
}