diff --git a/modules/effects/migrations/18_0_0-beta/index.spec.ts b/modules/effects/migrations/18_0_0-beta/index.spec.ts new file mode 100644 index 0000000000..0f54268f9f --- /dev/null +++ b/modules/effects/migrations/18_0_0-beta/index.spec.ts @@ -0,0 +1,142 @@ +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import { createWorkspace } from '@ngrx/schematics-core/testing'; +import { tags } from '@angular-devkit/core'; +import * as path from 'path'; + +describe('Effects Migration to 18.0.0-beta', () => { + const collectionPath = path.join(__dirname, '../migration.json'); + const schematicRunner = new SchematicTestRunner('schematics', collectionPath); + + let appTree: UnitTestTree; + + beforeEach(async () => { + appTree = await createWorkspace(schematicRunner, appTree); + }); + + const verifySchematic = async (input: string, output: string) => { + appTree.create('main.ts', input); + + const tree = await schematicRunner.runSchematic( + `ngrx-effects-migration-18-beta`, + {}, + appTree + ); + + const actual = tree.readContent('main.ts'); + + expect(actual).toBe(output); + }; + + describe('replacements', () => { + it('should replace the import', async () => { + const input = tags.stripIndent` +import { concatLatestFrom } from '@ngrx/effects'; + +@Injectable() +export class SomeEffects { + +} + `; + const output = tags.stripIndent` +import { concatLatestFrom } from '@ngrx/operators'; + +@Injectable() +export class SomeEffects { + +} + `; + + await verifySchematic(input, output); + }); + + it('should also work with " in imports', async () => { + const input = tags.stripIndent` +import { concatLatestFrom } from "@ngrx/effects"; + +@Injectable() +export class SomeEffects { + +} + `; + const output = tags.stripIndent` +import { concatLatestFrom } from '@ngrx/operators'; + +@Injectable() +export class SomeEffects { + +} + `; + await verifySchematic(input, output); + }); + + it('should replace if multiple imports are inside an import statement', async () => { + const input = tags.stripIndent` +import { Actions, concatLatestFrom } from '@ngrx/effects'; + +@Injectable() +export class SomeEffects { + actions$ = inject(Actions); + +} + `; + const output = tags.stripIndent` +import { Actions } from '@ngrx/effects'; +import { concatLatestFrom } from '@ngrx/operators'; + +@Injectable() +export class SomeEffects { + actions$ = inject(Actions); + +} + `; + + await verifySchematic(input, output); + }); + + it('should add concatLatestFrom to existing import', async () => { + const input = tags.stripIndent` +import { Actions, concatLatestFrom } from '@ngrx/effects'; +import { tapResponse } from '@ngrx/operators'; + +@Injectable() +export class SomeEffects { + actions$ = inject(Actions); + +} + `; + const output = tags.stripIndent` +import { Actions } from '@ngrx/effects'; +import { tapResponse, concatLatestFrom } from '@ngrx/operators'; + +@Injectable() +export class SomeEffects { + actions$ = inject(Actions); + +} + `; + await verifySchematic(input, output); + }); + }); + + it('should add if they are missing', async () => { + const originalPackageJson = JSON.parse( + appTree.readContent('/package.json') + ); + expect(originalPackageJson.dependencies['@ngrx/operators']).toBeUndefined(); + expect( + originalPackageJson.devDependencies['@ngrx/operators'] + ).toBeUndefined(); + + const tree = await schematicRunner.runSchematic( + `ngrx-effects-migration-18-beta`, + {}, + appTree + ); + + const packageJson = JSON.parse(tree.readContent('/package.json')); + expect(packageJson.dependencies['@ngrx/operators']).toBeDefined(); + }); +}); diff --git a/modules/effects/migrations/18_0_0-beta/index.ts b/modules/effects/migrations/18_0_0-beta/index.ts new file mode 100644 index 0000000000..31718ed81c --- /dev/null +++ b/modules/effects/migrations/18_0_0-beta/index.ts @@ -0,0 +1,157 @@ +import * as ts from 'typescript'; +import { + Tree, + Rule, + chain, + SchematicContext, +} from '@angular-devkit/schematics'; +import { + addPackageToPackageJson, + Change, + commitChanges, + createReplaceChange, + InsertChange, + visitTSSourceFiles, +} from '../../schematics-core'; +import * as os from 'os'; +import { createRemoveChange } from '../../schematics-core/utility/change'; + +export function migrateConcatLatestFromImport(): Rule { + return (tree: Tree, ctx: SchematicContext) => { + const changes: Change[] = []; + addPackageToPackageJson(tree, 'dependencies', '@ngrx/operators', '^18.0.0'); + + visitTSSourceFiles(tree, (sourceFile) => { + const importDeclarations = new Array(); + getImportDeclarations(sourceFile, importDeclarations); + + const effectsImportsAndDeclaration = importDeclarations + .map((effectsImportDeclaration) => { + const effectsImports = getEffectsNamedBinding( + effectsImportDeclaration + ); + if (effectsImports) { + return { effectsImports, effectsImportDeclaration }; + } else { + return undefined; + } + }) + .find(Boolean); + + if (!effectsImportsAndDeclaration) { + return; + } + + const { effectsImports, effectsImportDeclaration } = + effectsImportsAndDeclaration; + + const operatorsImportDeclaration = importDeclarations.find((node) => + node.moduleSpecifier.getText().includes('@ngrx/operators') + ); + + const otherEffectsImports = effectsImports.elements + .filter((element) => element.name.getText() !== 'concatLatestFrom') + .map((element) => element.name.getText()) + .join(', '); + + // Remove `concatLatestFrom` from @ngrx/effects and leave the other imports + if (otherEffectsImports) { + changes.push( + createReplaceChange( + sourceFile, + effectsImportDeclaration, + effectsImportDeclaration.getText(), + `import { ${otherEffectsImports} } from '@ngrx/effects';` + ) + ); + } + // Remove complete @ngrx/effects import because it contains only `concatLatestFrom` + else { + changes.push( + createRemoveChange( + sourceFile, + effectsImportDeclaration, + effectsImportDeclaration.getStart(), + effectsImportDeclaration.getEnd() + 1 + ) + ); + } + + let importAppendedInExistingDeclaration = false; + if (operatorsImportDeclaration?.importClause?.namedBindings) { + const bindings = operatorsImportDeclaration.importClause.namedBindings; + if (ts.isNamedImports(bindings)) { + // Add import to existing @ngrx/operators + const updatedImports = [ + ...bindings.elements.map((element) => element.name.getText()), + 'concatLatestFrom', + ]; + const newOperatorsImport = `import { ${updatedImports.join( + ', ' + )} } from '@ngrx/operators';`; + changes.push( + createReplaceChange( + sourceFile, + operatorsImportDeclaration, + operatorsImportDeclaration.getText(), + newOperatorsImport + ) + ); + importAppendedInExistingDeclaration = true; + } + } + + if (!importAppendedInExistingDeclaration) { + // Add new @ngrx/operators import line + const newOperatorsImport = `import { concatLatestFrom } from '@ngrx/operators';`; + changes.push( + new InsertChange( + sourceFile.fileName, + effectsImportDeclaration.getEnd() + 1, + `${newOperatorsImport}${os.EOL}` + ) + ); + } + + commitChanges(tree, sourceFile.fileName, changes); + + if (changes.length) { + ctx.logger.info( + `[@ngrx/effects] Updated concatLatestFrom to import from '@ngrx/operators'` + ); + } + }); + }; +} + +function getImportDeclarations( + node: ts.Node, + imports: ts.ImportDeclaration[] +): void { + if (ts.isImportDeclaration(node)) { + imports.push(node); + } + + ts.forEachChild(node, (childNode) => + getImportDeclarations(childNode, imports) + ); +} + +function getEffectsNamedBinding( + node: ts.ImportDeclaration +): ts.NamedImports | null { + const namedBindings = node?.importClause?.namedBindings; + if ( + node.moduleSpecifier.getText().includes('@ngrx/effects') && + namedBindings && + ts.isNamedImports(namedBindings) + ) { + return namedBindings; + } + + return null; +} + +export default function (): Rule { + return chain([migrateConcatLatestFromImport()]); +} diff --git a/modules/effects/migrations/migration.json b/modules/effects/migrations/migration.json index ffadfa8dc2..e9e7bc46e3 100644 --- a/modules/effects/migrations/migration.json +++ b/modules/effects/migrations/migration.json @@ -20,6 +20,11 @@ "description": "The road to v15 beta", "version": "15-beta", "factory": "./15_0_0-beta/index" + }, + "ngrx-effects-migration-18-beta": { + "description": "As of NgRx v18, the `concatLatestFrom` import has been removed from `@ngrx/effects` in favor of the `@ngrx/operators` package.", + "version": "18-beta", + "factory": "./18_0_0-beta/index" } } }