Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(component): add migration for LetModule and PushModule (#3872)
- Loading branch information
1 parent
d65c188
commit 5f07eda
Showing
3 changed files
with
387 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
})); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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()]); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters