Skip to content

Commit

Permalink
feat(component): add migration for LetModule and PushModule (#3872)
Browse files Browse the repository at this point in the history
  • Loading branch information
markostanimirovic committed May 5, 2023
1 parent d65c188 commit 5f07eda
Show file tree
Hide file tree
Showing 3 changed files with 387 additions and 0 deletions.
166 changes: 166 additions & 0 deletions modules/component/migrations/16_0_0/index.spec.ts
@@ -0,0 +1,166 @@
import * as path from 'path';
import { waitForAsync } from '@angular/core/testing';
import { Tree } from '@angular-devkit/schematics';
import {
SchematicTestRunner,
UnitTestTree,
} from '@angular-devkit/schematics/testing';
import { createPackageJson } from '@ngrx/schematics-core/testing/create-package';

describe('Component Migration 16_0_0', () => {
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);
});

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

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

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

expect(file).toBe(expected);
}));

it(`should replace the ${module} in standalone component with ${declarable}`, waitForAsync(async () => {
const input = `
import { ${module} } from '@ngrx/component';
@Component({
imports: [
AuthModule,
${module}
]
})
export class SomeStandaloneComponent {}
`;
const expected = `
import { ${declarable} } from '@ngrx/component';
@Component({
imports: [
AuthModule,
${declarable}
]
})
export class SomeStandaloneComponent {}
`;

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

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

expect(file).toBe(expected);
}));

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

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

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

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

const letModuleText = 'LetModule';
const letDirectiveText = 'LetDirective';
const pushModuleText = 'PushModule';
const pushPipeText = 'PushPipe';
const moduleLocations = {
imports: ['NgModule', 'Component'],
exports: ['NgModule'],
};

function migrateToStandaloneAPIs() {
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 = findNgModuleReplacements(sourceFile);
const possibleModulesUsageCount =
findPossibleModulesUsageCount(sourceFile);
const importAdditionReplacements = findImportDeclarationAdditions(
sourceFile,
componentImports
);
const jsImportDeclarationReplacements =
possibleModulesUsageCount >
ngModuleReplacements.length + importAdditionReplacements.length
? importAdditionReplacements
: findImportDeclarationReplacements(sourceFile, componentImports);

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

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

function findImportDeclarationReplacements(
sourceFile: ts.SourceFile,
imports: ts.ImportDeclaration[]
) {
return findImportDeclarations(sourceFile, imports)
.map(({ specifier, oldText, newText }) =>
!!specifier && !!oldText
? createReplaceChange(sourceFile, specifier, oldText, newText)
: undefined
)
.filter((change) => !!change) as Array<ReplaceChange>;
}

function findImportDeclarationAdditions(
sourceFile: ts.SourceFile,
imports: ts.ImportDeclaration[]
) {
return findImportDeclarations(sourceFile, imports)
.map(({ specifier, oldText, newText }) =>
!!specifier && !!oldText
? createReplaceChange(
sourceFile,
specifier,
oldText,
`${oldText}, ${newText}`
)
: undefined
)
.filter((change) => !!change) as Array<ReplaceChange>;
}

function findImportDeclarations(
sourceFile: ts.SourceFile,
imports: ts.ImportDeclaration[]
) {
return 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 === letModuleText) {
return {
hit: true,
specifier,
oldText: specifier.name.text,
newText: letDirectiveText,
};
}

if (specifier.name.text === pushModuleText) {
return {
hit: true,
specifier,
oldText: specifier.name.text,
newText: pushPipeText,
};
}

// if `LetModule` import is renamed
if (specifier.propertyName?.text === letModuleText) {
return {
hit: true,
specifier,
oldText: specifier.propertyName.text,
newText: letDirectiveText,
};
}

// if `PushModule` import is renamed
if (specifier.propertyName?.text === pushModuleText) {
return {
hit: true,
specifier,
oldText: specifier.propertyName.text,
newText: pushPipeText,
};
}

return { hit: false };
})
.filter(({ hit }) => hit);
}

function findPossibleModulesUsageCount(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 === letModuleText || node.text === pushModuleText)
) {
count = count + 1;
}

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

function findNgModuleReplacements(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 === letModuleText || node.text === pushModuleText) &&
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,
oldText: node.text,
newText:
node.text === letModuleText ? letDirectiveText : pushPipeText,
};
}
}
}
}

if (change) {
changes.push(
createReplaceChange(
sourceFile,
change.node,
change.oldText,
change.newText
)
);
}

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

export default function (): Rule {
return chain([migrateToStandaloneAPIs()]);
}
5 changes: 5 additions & 0 deletions modules/component/migrations/migration.json
Expand Up @@ -5,6 +5,11 @@
"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"
},
"ngrx-component-migration-16": {
"description": "As of NgRx v16, `LetModule` and `PushModule` are deprecated in favor of standalone `LetDirective` and `PushPipe`.",
"version": "16.0.0",
"factory": "./16_0_0/index"
}
}
}

0 comments on commit 5f07eda

Please sign in to comment.