diff --git a/modules/component/migrations/15_0_0-beta/index.spec.ts b/modules/component/migrations/15_0_0-beta/index.spec.ts new file mode 100644 index 0000000000..9a9831e0bc --- /dev/null +++ b/modules/component/migrations/15_0_0-beta/index.spec.ts @@ -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); + }) + ); + }); +}); diff --git a/modules/component/migrations/15_0_0-beta/index.ts b/modules/component/migrations/15_0_0-beta/index.ts new file mode 100644 index 0000000000..b9d20f1e01 --- /dev/null +++ b/modules/component/migrations/15_0_0-beta/index.ts @@ -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; + + 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; + + 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()]); +} diff --git a/modules/component/migrations/migration.json b/modules/component/migrations/migration.json index 00b46c7ac6..3b129f2fa8 100644 --- a/modules/component/migrations/migration.json +++ b/modules/component/migrations/migration.json @@ -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" + } + } }