Skip to content

Commit

Permalink
feat(component): add migration for replacing ReactiveComponentModule (#…
Browse files Browse the repository at this point in the history
…3506)

Closes #3491
  • Loading branch information
david-shortman authored Oct 17, 2022
1 parent 899afe7 commit 49c6cf3
Show file tree
Hide file tree
Showing 3 changed files with 390 additions and 1 deletion.
159 changes: 159 additions & 0 deletions modules/component/migrations/15_0_0-beta/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { Tree } from '@angular-devkit/schematics';
import {
SchematicTestRunner,
UnitTestTree,
} from '@angular-devkit/schematics/testing';
import * as path from 'path';
import { createPackageJson } from '@ngrx/schematics-core/testing/create-package';
import { waitForAsync } from '@angular/core/testing';

describe('Component Migration 15_0_0-beta', () => {
let appTree: UnitTestTree;
const collectionPath = path.join(__dirname, '../migration.json');
const pkgName = 'component';

beforeEach(() => {
appTree = new UnitTestTree(Tree.empty());
appTree.create(
'/tsconfig.json',
`
{
"include": [**./*.ts"]
}
`
);
createPackageJson('', pkgName, appTree);
});

describe('Replace ReactiveComponentModule', () => {
it(
`should replace the ReactiveComponentModule in NgModules with LetModule and PushModule`,
waitForAsync(async () => {
const input = `
import { ReactiveComponentModule } from '@ngrx/component';
@NgModule({
imports: [
AuthModule,
AppRoutingModule,
ReactiveComponentModule,
CoreModule,
],
exports: [ReactiveComponentModule],
bootstrap: [AppComponent]
})
export class AppModule {}
`;
const expected = `
import { LetModule, PushModule } from '@ngrx/component';
@NgModule({
imports: [
AuthModule,
AppRoutingModule,
LetModule, PushModule,
CoreModule,
],
exports: [LetModule, PushModule],
bootstrap: [AppComponent]
})
export class AppModule {}
`;

appTree.create('./app.module.ts', input);
const runner = new SchematicTestRunner('schematics', collectionPath);

const newTree = await runner
.runSchematicAsync(`ngrx-${pkgName}-migration-15-beta`, {}, appTree)
.toPromise();
const file = newTree.readContent('app.module.ts');

expect(file).toBe(expected);
})
);
it(
`should replace the ReactiveComponentModule in standalone components with LetModule and PushModule`,
waitForAsync(async () => {
const input = `
import { ReactiveComponentModule } from '@ngrx/component';
@Component({
imports: [
AuthModule,
ReactiveComponentModule
]
})
export class SomeStandaloneComponent {}
`;
const expected = `
import { LetModule, PushModule } from '@ngrx/component';
@Component({
imports: [
AuthModule,
LetModule, PushModule
]
})
export class SomeStandaloneComponent {}
`;

appTree.create('./app.module.ts', input);
const runner = new SchematicTestRunner('schematics', collectionPath);

const newTree = await runner
.runSchematicAsync(`ngrx-${pkgName}-migration-15-beta`, {}, appTree)
.toPromise();
const file = newTree.readContent('app.module.ts');

expect(file).toBe(expected);
})
);
it(
`should not remove the ReactiveComponentModule JS import when used as a type`,
waitForAsync(async () => {
const input = `
import { ReactiveComponentModule } from '@ngrx/component';
const reactiveComponentModule: ReactiveComponentModule;
@NgModule({
imports: [
AuthModule,
AppRoutingModule,
ReactiveComponentModule,
CoreModule
],
bootstrap: [AppComponent]
})
export class AppModule {}
`;
const expected = `
import { ReactiveComponentModule, LetModule, PushModule } from '@ngrx/component';
const reactiveComponentModule: ReactiveComponentModule;
@NgModule({
imports: [
AuthModule,
AppRoutingModule,
LetModule, PushModule,
CoreModule
],
bootstrap: [AppComponent]
})
export class AppModule {}
`;

appTree.create('./app.module.ts', input);
const runner = new SchematicTestRunner('schematics', collectionPath);

const newTree = await runner
.runSchematicAsync(`ngrx-${pkgName}-migration-15-beta`, {}, appTree)
.toPromise();
const file = newTree.readContent('app.module.ts');

expect(file).toBe(expected);
})
);
});
});
224 changes: 224 additions & 0 deletions modules/component/migrations/15_0_0-beta/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import * as ts from 'typescript';
import { Rule, chain, Tree } from '@angular-devkit/schematics';
import {
visitTSSourceFiles,
commitChanges,
createReplaceChange,
ReplaceChange,
} from '../../schematics-core';

const reactiveComponentModuleText = 'ReactiveComponentModule';
const reactiveComponentModuleReplacement = 'LetModule, PushModule';
const moduleLocations = {
imports: ['NgModule', 'Component'],
exports: ['NgModule'],
};

function migrateReactiveComponentModule() {
return (tree: Tree) => {
visitTSSourceFiles(tree, (sourceFile) => {
const componentImports = sourceFile.statements
.filter(ts.isImportDeclaration)
.filter(({ moduleSpecifier }) =>
moduleSpecifier.getText(sourceFile).includes('@ngrx/component')
);

if (componentImports.length === 0) {
return;
}

const ngModuleReplacements =
findReactiveComponentModuleNgModuleReplacements(sourceFile);

const possibleUsagesOfReactiveComponentModuleCount =
findPossibleReactiveComponentModuleUsageCount(sourceFile);

const importAdditionReplacements =
findReactiveComponentModuleImportDeclarationAdditions(
sourceFile,
componentImports
);

const importUsagesCount = importAdditionReplacements.length;

const jsImportDeclarationReplacements =
possibleUsagesOfReactiveComponentModuleCount >
ngModuleReplacements.length + importUsagesCount
? importAdditionReplacements
: findReactiveComponentModuleImportDeclarationReplacements(
sourceFile,
componentImports
);

const changes = [
...jsImportDeclarationReplacements,
...ngModuleReplacements,
];

commitChanges(tree, sourceFile.fileName, changes);
});
};
}

function findReactiveComponentModuleImportDeclarationReplacements(
sourceFile: ts.SourceFile,
imports: ts.ImportDeclaration[]
) {
const changes = imports
.map((p) => (p?.importClause?.namedBindings as ts.NamedImports)?.elements)
.reduce(
(imports, curr) => imports.concat(curr ?? []),
[] as ts.ImportSpecifier[]
)
.map((specifier) => {
if (!ts.isImportSpecifier(specifier)) {
return { hit: false };
}

if (specifier.name.text === reactiveComponentModuleText) {
return { hit: true, specifier, text: specifier.name.text };
}

// if import is renamed
if (
specifier.propertyName &&
specifier.propertyName.text === reactiveComponentModuleText
) {
return { hit: true, specifier, text: specifier.propertyName.text };
}

return { hit: false };
})
.filter(({ hit }) => hit)
.map(({ specifier, text }) =>
!!specifier && !!text
? createReplaceChange(
sourceFile,
specifier,
text,
reactiveComponentModuleReplacement
)
: undefined
)
.filter((change) => !!change) as Array<ReplaceChange>;

return changes;
}

function findReactiveComponentModuleImportDeclarationAdditions(
sourceFile: ts.SourceFile,
imports: ts.ImportDeclaration[]
) {
const changes = imports
.map((p) => (p?.importClause?.namedBindings as ts.NamedImports)?.elements)
.reduce(
(imports, curr) => imports.concat(curr ?? []),
[] as ts.ImportSpecifier[]
)
.map((specifier) => {
if (!ts.isImportSpecifier(specifier)) {
return { hit: false };
}

if (specifier.name.text === reactiveComponentModuleText) {
return { hit: true, specifier, text: specifier.name.text };
}

// if import is renamed
if (
specifier.propertyName &&
specifier.propertyName.text === reactiveComponentModuleText
) {
return { hit: true, specifier, text: specifier.propertyName.text };
}

return { hit: false };
})
.filter(({ hit }) => hit)
.map(({ specifier, text }) =>
!!specifier && !!text
? createReplaceChange(
sourceFile,
specifier,
text,
`${text}, ${reactiveComponentModuleReplacement}`
)
: undefined
)
.filter((change) => !!change) as Array<ReplaceChange>;

return changes;
}

function findPossibleReactiveComponentModuleUsageCount(
sourceFile: ts.SourceFile
): number {
let count = 0;
ts.forEachChild(sourceFile, (node) => countUsages(node));
return count;

function countUsages(node: ts.Node) {
if (ts.isIdentifier(node) && node.text === reactiveComponentModuleText) {
count = count + 1;
}

ts.forEachChild(node, (childNode) => countUsages(childNode));
}
}

function findReactiveComponentModuleNgModuleReplacements(
sourceFile: ts.SourceFile
) {
const changes: ReplaceChange[] = [];
ts.forEachChild(sourceFile, (node) => find(node, changes));
return changes;

function find(node: ts.Node, changes: ReplaceChange[]) {
let change = undefined;

if (
ts.isIdentifier(node) &&
node.text === reactiveComponentModuleText &&
ts.isArrayLiteralExpression(node.parent) &&
ts.isPropertyAssignment(node.parent.parent)
) {
const property = node.parent.parent;
if (ts.isIdentifier(property.name)) {
const propertyName = String(property.name.escapedText);
if (Object.keys(moduleLocations).includes(propertyName)) {
const decorator = property.parent.parent.parent;
if (
ts.isDecorator(decorator) &&
ts.isCallExpression(decorator.expression) &&
ts.isIdentifier(decorator.expression.expression) &&
moduleLocations[propertyName as 'imports' | 'exports'].includes(
String(decorator.expression.expression.escapedText)
)
) {
change = {
node: node,
text: node.text,
};
}
}
}
}

if (change) {
changes.push(
createReplaceChange(
sourceFile,
change.node,
change.text,
reactiveComponentModuleReplacement
)
);
}

ts.forEachChild(node, (childNode) => find(childNode, changes));
}
}

export default function (): Rule {
return chain([migrateReactiveComponentModule()]);
}
8 changes: 7 additions & 1 deletion modules/component/migrations/migration.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
{
"$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json",
"schematics": {}
"schematics": {
"ngrx-component-migration-15-beta": {
"description": "As of NgRx v14, `ReactiveComponentModule` is deprecated. It is replaced by `LetModule` and `PushModule`.",
"version": "15.0.0-beta",
"factory": "./15_0_0-beta/index"
}
}
}

0 comments on commit 49c6cf3

Please sign in to comment.