From aafeb3658a6b1195c5a8f85f88926741931351db Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Thu, 9 May 2024 00:07:26 +0200 Subject: [PATCH] feat(component-store): add migrator for `tapResponse` Migrates any `import` of `tapResponse` from `@ngrx/component-store` to `@ngrx/operators`. Fixes #4261 --- .../migrations/18_0_0-beta/index.spec.ts | 138 +++++++++++++++ .../migrations/18_0_0-beta/index.ts | 157 ++++++++++++++++++ .../component-store/migrations/migration.json | 8 +- 3 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 modules/component-store/migrations/18_0_0-beta/index.spec.ts create mode 100644 modules/component-store/migrations/18_0_0-beta/index.ts diff --git a/modules/component-store/migrations/18_0_0-beta/index.spec.ts b/modules/component-store/migrations/18_0_0-beta/index.spec.ts new file mode 100644 index 0000000000..fbdd331e36 --- /dev/null +++ b/modules/component-store/migrations/18_0_0-beta/index.spec.ts @@ -0,0 +1,138 @@ +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('ComponentStore 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-component-store-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 { tapResponse } from '@ngrx/component-store'; + +@Injectable() +export class MyStore extends ComponentStore { + +} + `; + const output = tags.stripIndent` +import { tapResponse } from '@ngrx/operators'; + +@Injectable() +export class MyStore extends ComponentStore { + +} + `; + + await verifySchematic(input, output); + }); + + it('should also work with " in imports', async () => { + const input = tags.stripIndent` +import { tapResponse } from "@ngrx/component-store"; + +@Injectable() +export class MyStore extends ComponentStore { + +} + `; + const output = tags.stripIndent` +import { tapResponse } from '@ngrx/operators'; + +@Injectable() +export class MyStore extends ComponentStore { + +} + `; + await verifySchematic(input, output); + }); + + it('should replace if multiple imports are inside an import statement', async () => { + const input = tags.stripIndent` +import { ComponentStore, tapResponse } from '@ngrx/component-store'; + +@Injectable() +export class MyStore extends ComponentStore { + +} + `; + const output = tags.stripIndent` +import { ComponentStore } from '@ngrx/component-store'; +import { tapResponse } from '@ngrx/operators'; + +@Injectable() +export class MyStore extends ComponentStore { + +} + `; + + await verifySchematic(input, output); + }); + + it('should add tapResponse to existing import', async () => { + const input = tags.stripIndent` +import { ComponentStore, tapResponse } from '@ngrx/component-store'; +import { concatLatestFrom } from '@ngrx/operators'; + +@Injectable() +export class MyStore extends ComponentStore { + +} + `; + const output = tags.stripIndent` +import { ComponentStore } from '@ngrx/component-store'; +import { concatLatestFrom, tapResponse } from '@ngrx/operators'; + +@Injectable() +export class MyStore extends ComponentStore { + +} + `; + await verifySchematic(input, output); + }); + }); + + it('should add @ngrx/operators 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-component-store-migration-18-beta`, + {}, + appTree + ); + + const packageJson = JSON.parse(tree.readContent('/package.json')); + expect(packageJson.dependencies['@ngrx/operators']).toBeDefined(); + }); +}); diff --git a/modules/component-store/migrations/18_0_0-beta/index.ts b/modules/component-store/migrations/18_0_0-beta/index.ts new file mode 100644 index 0000000000..c1039bc3ad --- /dev/null +++ b/modules/component-store/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 migrateTapResponseImport(): 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 componentStoreImportsAndDeclaration = importDeclarations + .map((componentStoreImportDeclaration) => { + const componentStoreImports = getComponentStoreNamedBinding( + componentStoreImportDeclaration + ); + if (componentStoreImports) { + return { componentStoreImports, componentStoreImportDeclaration }; + } else { + return undefined; + } + }) + .find(Boolean); + + if (!componentStoreImportsAndDeclaration) { + return; + } + + const { componentStoreImports, componentStoreImportDeclaration } = + componentStoreImportsAndDeclaration; + + const operatorsImportDeclaration = importDeclarations.find((node) => + node.moduleSpecifier.getText().includes('@ngrx/operators') + ); + + const otherComponentStoreImports = componentStoreImports.elements + .filter((element) => element.name.getText() !== 'tapResponse') + .map((element) => element.name.getText()) + .join(', '); + + // Remove `tapResponse` from @ngrx/component-store and leave the other imports + if (otherComponentStoreImports) { + changes.push( + createReplaceChange( + sourceFile, + componentStoreImportDeclaration, + componentStoreImportDeclaration.getText(), + `import { ${otherComponentStoreImports} } from '@ngrx/component-store';` + ) + ); + } + // Remove complete @ngrx/component-store import because it contains only `tapResponse` + else { + changes.push( + createRemoveChange( + sourceFile, + componentStoreImportDeclaration, + componentStoreImportDeclaration.getStart(), + componentStoreImportDeclaration.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()), + 'tapResponse', + ]; + 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 { tapResponse } from '@ngrx/operators';`; + changes.push( + new InsertChange( + sourceFile.fileName, + componentStoreImportDeclaration.getEnd() + 1, + `${newOperatorsImport}${os.EOL}` + ) + ); + } + + commitChanges(tree, sourceFile.fileName, changes); + + if (changes.length) { + ctx.logger.info( + `[@ngrx/component-store] Updated tapResponse 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 getComponentStoreNamedBinding( + node: ts.ImportDeclaration +): ts.NamedImports | null { + const namedBindings = node?.importClause?.namedBindings; + if ( + node.moduleSpecifier.getText().includes('@ngrx/component-store') && + namedBindings && + ts.isNamedImports(namedBindings) + ) { + return namedBindings; + } + + return null; +} + +export default function (): Rule { + return chain([migrateTapResponseImport()]); +} diff --git a/modules/component-store/migrations/migration.json b/modules/component-store/migrations/migration.json index 00b46c7ac6..01f9b4de87 100644 --- a/modules/component-store/migrations/migration.json +++ b/modules/component-store/migrations/migration.json @@ -1,4 +1,10 @@ { "$schema": "../../../node_modules/@angular-devkit/schematics/collection-schema.json", - "schematics": {} + "schematics": { + "ngrx-component-store-migration-18-beta": { + "description": "As of NgRx v18, the `tapResponse` import has been removed from `@ngrx/component-store` in favor of the `@ngrx/operators` package.", + "version": "18-beta", + "factory": "./18_0_0-beta/index" + } + } }