From 4fdb3ecc4c7f95908d500a3e85f1339082200838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leosvel=20P=C3=A9rez=20Espinosa?= Date: Fri, 5 May 2023 08:40:47 +0100 Subject: [PATCH] feat(angular): update ngrx packages to v16 --- package.json | 6 +- packages/angular/migrations.json | 21 ++ ...ators-imports-to-ngrx-router-store.spec.ts | 309 ++++++++++++++++++ ...-operators-imports-to-ngrx-router-store.ts | 184 +++++++++++ packages/angular/src/utils/versions.ts | 2 +- pnpm-lock.yaml | 42 +-- 6 files changed, 539 insertions(+), 25 deletions(-) create mode 100644 packages/angular/src/migrations/update-16-1-4/switch-data-persistence-operators-imports-to-ngrx-router-store.spec.ts create mode 100644 packages/angular/src/migrations/update-16-1-4/switch-data-persistence-operators-imports-to-ngrx-router-store.ts diff --git a/package.json b/package.json index 4b5e7e4820def6..d5b5ac466950b1 100644 --- a/package.json +++ b/package.json @@ -58,9 +58,9 @@ "@nestjs/schematics": "^9.1.0", "@nestjs/swagger": "^6.0.0", "@nestjs/testing": "^9.0.0", - "@ngrx/effects": "~15.3.0", - "@ngrx/router-store": "~15.3.0", - "@ngrx/store": "~15.3.0", + "@ngrx/effects": "~16.0.0-rc.0", + "@ngrx/router-store": "~16.0.0-rc.0", + "@ngrx/store": "~16.0.0-rc.0", "@nguniversal/builders": "~16.0.0", "@nx/cypress": "16.1.0-rc.0", "@nx/devkit": "16.1.0-rc.0", diff --git a/packages/angular/migrations.json b/packages/angular/migrations.json index 98d664331663fc..7503a94aeaffb0 100644 --- a/packages/angular/migrations.json +++ b/packages/angular/migrations.json @@ -236,6 +236,15 @@ }, "description": "Update the @angular/cli package version to ~16.0.0.", "factory": "./src/migrations/update-16-1-0/update-angular-cli" + }, + "switch-data-persistence-operators-imports-to-ngrx-router-store": { + "cli": "nx", + "version": "16.1.4-beta.0", + "requires": { + "@ngrx/store": ">=16.0.0" + }, + "description": "Switch the data persistence operator imports to '@ngrx/router-store/data-persistence'.", + "factory": "./src/migrations/update-16-1-4/switch-data-persistence-operators-imports-to-ngrx-router-store" } }, "packageJsonUpdates": { @@ -1126,6 +1135,18 @@ "alwaysAddToPackageJson": false } } + }, + "16.1.4-ngrx": { + "version": "16.1.4-beta.0", + "requires": { + "@angular/core": "^16.0.0" + }, + "packages": { + "@ngrx/store": { + "version": "~16.0.0-rc.0", + "alwaysAddToPackageJson": false + } + } } } } diff --git a/packages/angular/src/migrations/update-16-1-4/switch-data-persistence-operators-imports-to-ngrx-router-store.spec.ts b/packages/angular/src/migrations/update-16-1-4/switch-data-persistence-operators-imports-to-ngrx-router-store.spec.ts new file mode 100644 index 00000000000000..5866c2a4a2325c --- /dev/null +++ b/packages/angular/src/migrations/update-16-1-4/switch-data-persistence-operators-imports-to-ngrx-router-store.spec.ts @@ -0,0 +1,309 @@ +import { ProjectGraph, Tree, addProjectConfiguration } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import migration from './switch-data-persistence-operators-imports-to-ngrx-router-store'; + +let projectGraph: ProjectGraph; +jest.mock('@nx/devkit', () => ({ + ...jest.requireActual('@nx/devkit'), + createProjectGraphAsync: jest + .fn() + .mockImplementation(async () => projectGraph), +})); + +describe('switch-data-persistence-operators-imports-to-ngrx-router-store migration', () => { + let tree: Tree; + const file = 'apps/app1/src/app/+state/users.effects.ts'; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + addProjectConfiguration(tree, 'app1', { root: 'apps/app1' }); + projectGraph = { + dependencies: { + app1: [{ source: 'app1', target: 'npm:@nx/angular', type: 'static' }], + }, + nodes: { + app1: { + data: { + files: [ + { + file, + hash: '', + dependencies: [ + { + source: 'app1', + target: 'npm:@nx/angular', + type: 'static', + }, + ], + }, + ], + root: 'apps/app1', + }, + name: 'app1', + type: 'app', + }, + }, + }; + }); + + it('should do nothing when there are no imports from the angular plugin', async () => { + tree.write( + file, + `import { Actions, createEffect, ofType } from '@ngrx/effects'; + + @Injectable() + class UsersEffects {} + ` + ); + + await migration(tree); + + expect(tree.read(file, 'utf-8')).toMatchInlineSnapshot(` + "import { Actions, createEffect, ofType } from '@ngrx/effects'; + + @Injectable() + class UsersEffects {} + " + `); + }); + + it('should not replace the import path when no operator is imported', async () => { + tree.write( + file, + `import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { foo } from '@nx/angular'; + + @Injectable() + class UsersEffects {} + ` + ); + + await migration(tree); + + expect(tree.read(file, 'utf-8')).toMatchInlineSnapshot(` + "import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { foo } from '@nx/angular'; + + @Injectable() + class UsersEffects {} + " + `); + }); + + it('should not match imports from angular plugin secondary entry points', async () => { + tree.write( + file, + `import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch } from '@nx/angular/mf'; + + @Injectable() + class UsersEffects {} + ` + ); + + await migration(tree); + + expect(tree.read(file, 'utf-8')).toMatchInlineSnapshot(` + "import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch } from '@nx/angular/mf'; + + @Injectable() + class UsersEffects {} + " + `); + }); + + it('should replace the import path in-place when it is importing an operator', async () => { + tree.write( + file, + `import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch } from '@nx/angular'; + + @Injectable() + class UsersEffects {} + ` + ); + + await migration(tree); + + expect(tree.read(file, 'utf-8')).toMatchInlineSnapshot(` + "import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch } from '@ngrx/router-store/data-persistence'; + + @Injectable() + class UsersEffects {} + " + `); + }); + + it('should match imports using @nrwl/angular', async () => { + tree.write( + file, + `import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch } from '@nrwl/angular'; + + @Injectable() + class UsersEffects {} + ` + ); + + await migration(tree); + + expect(tree.read(file, 'utf-8')).toMatchInlineSnapshot(` + "import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch } from '@ngrx/router-store/data-persistence'; + + @Injectable() + class UsersEffects {} + " + `); + }); + + it('should support multiple operators imports', async () => { + tree.write( + file, + `import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch, navigation } from '@nx/angular'; + + @Injectable() + class UsersEffects {} + ` + ); + + await migration(tree); + + expect(tree.read(file, 'utf-8')).toMatchInlineSnapshot(` + "import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch, navigation } from '@ngrx/router-store/data-persistence'; + + @Injectable() + class UsersEffects {} + " + `); + }); + + it('should add a separate import statement when there are operator and non-operator imports', async () => { + tree.write( + file, + `import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch, foo, navigation } from '@nx/angular'; + + @Injectable() + class UsersEffects {} + ` + ); + + await migration(tree); + + expect(tree.read(file, 'utf-8')).toMatchInlineSnapshot(` + "import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch, navigation } from '@ngrx/router-store/data-persistence'; + import { foo } from '@nx/angular'; + + @Injectable() + class UsersEffects {} + " + `); + }); + + it('should support multiple import statements and import paths', async () => { + tree.write( + file, + `import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch } from '@nx/angular'; + import { navigation } from '@nrwl/angular'; + + @Injectable() + class UsersEffects {} + ` + ); + + await migration(tree); + + expect(tree.read(file, 'utf-8')).toMatchInlineSnapshot(` + "import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch } from '@ngrx/router-store/data-persistence'; + import { navigation } from '@ngrx/router-store/data-persistence'; + + @Injectable() + class UsersEffects {} + " + `); + }); + + it('should support renamed import symbols', async () => { + tree.write( + file, + `import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch as customFetch } from '@nx/angular'; + + @Injectable() + class UsersEffects {} + ` + ); + + await migration(tree); + + expect(tree.read(file, 'utf-8')).toMatchInlineSnapshot(` + "import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch as customFetch } from '@ngrx/router-store/data-persistence'; + + @Injectable() + class UsersEffects {} + " + `); + }); + + it('should support multiple imports with renamed and non-renamed symbols', async () => { + tree.write( + file, + `import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch as customFetch, navigation } from '@nx/angular'; + + @Injectable() + class UsersEffects {} + ` + ); + + await migration(tree); + + expect(tree.read(file, 'utf-8')).toMatchInlineSnapshot(` + "import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { + fetch as customFetch, + navigation, + } from '@ngrx/router-store/data-persistence'; + + @Injectable() + class UsersEffects {} + " + `); + }); + + it('should add a separate import statement even with renamed symbols', async () => { + tree.write( + file, + `import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { fetch as customFetch, foo, navigation } from '@nx/angular'; + + @Injectable() + class UsersEffects {} + ` + ); + + await migration(tree); + + expect(tree.read(file, 'utf-8')).toMatchInlineSnapshot(` + "import { Actions, createEffect, ofType } from '@ngrx/effects'; + import { + fetch as customFetch, + navigation, + } from '@ngrx/router-store/data-persistence'; + import { foo } from '@nx/angular'; + + @Injectable() + class UsersEffects {} + " + `); + }); +}); diff --git a/packages/angular/src/migrations/update-16-1-4/switch-data-persistence-operators-imports-to-ngrx-router-store.ts b/packages/angular/src/migrations/update-16-1-4/switch-data-persistence-operators-imports-to-ngrx-router-store.ts new file mode 100644 index 00000000000000..21f5c6353e24b9 --- /dev/null +++ b/packages/angular/src/migrations/update-16-1-4/switch-data-persistence-operators-imports-to-ngrx-router-store.ts @@ -0,0 +1,184 @@ +import type { FileData, Tree } from '@nx/devkit'; +import { + addDependenciesToPackageJson, + formatFiles, + readJson, +} from '@nx/devkit'; +import { ensureTypescript } from '@nx/js/src/utils/typescript/ensure-typescript'; +import type { ImportDeclaration, ImportSpecifier, Node } from 'typescript'; +import { FileChangeRecorder } from '../../utils/file-change-recorder'; +import { ngrxVersion } from '../../utils/versions'; +import { getProjectsFilteredByDependencies } from '../utils/projects'; + +let tsquery: typeof import('@phenomnomnominal/tsquery').tsquery; + +const angularPluginTargetNames = ['npm:@nx/angular', 'npm:@nrwl/angular']; +const dataPersistenceOperators = [ + 'fetch', + 'navigation', + 'optimisticUpdate', + 'pessimisticUpdate', +]; +const newImportPath = '@ngrx/router-store/data-persistence'; + +export default async function (tree: Tree): Promise { + const projects = await getProjectsFilteredByDependencies( + tree, + angularPluginTargetNames + ); + + if (!projects.length) { + return; + } + + ensureTypescript(); + tsquery = require('@phenomnomnominal/tsquery').tsquery; + + const filesWithNxAngularImports: FileData[] = []; + for (const { graphNode } of projects) { + const files = filterFilesWithNxAngularDep(graphNode.data.files); + filesWithNxAngularImports.push(...files); + } + + let isAnyFileUsingDataPersistence = false; + for (const { file } of filesWithNxAngularImports) { + const updated = replaceDataPersistenceInFile(tree, file); + isAnyFileUsingDataPersistence ||= updated; + } + + if (isAnyFileUsingDataPersistence) { + addNgrxRouterStoreIfNotInstalled(tree); + + await formatFiles(tree); + } +} + +function replaceDataPersistenceInFile(tree: Tree, file: string): boolean { + const fileContents = tree.read(file, 'utf-8'); + const fileAst = tsquery.ast(fileContents); + + // "\\u002F" is the unicode code for "/", there's an issue with the query parser + // that prevents using "/" directly in regex queries + // https://github.com/estools/esquery/issues/68#issuecomment-415597670 + const NX_ANGULAR_IMPORT_SELECTOR = + 'ImportDeclaration:has(StringLiteral[value=/@(nx|nrwl)\\u002Fangular$/])'; + const nxAngularImports = tsquery( + fileAst, + NX_ANGULAR_IMPORT_SELECTOR, + { visitAllChildren: true } + ); + + if (!nxAngularImports.length) { + return false; + } + + const recorder = new FileChangeRecorder(tree, file); + + const IMPORT_SPECIFIERS_SELECTOR = + 'ImportClause NamedImports ImportSpecifier'; + for (const importDeclaration of nxAngularImports) { + const importSpecifiers = tsquery( + importDeclaration, + IMPORT_SPECIFIERS_SELECTOR, + { visitAllChildren: true } + ); + + if (!importSpecifiers.length) { + continue; + } + + // no imported symbol is a data persistence operator, skip + if (importSpecifiers.every((i) => !isOperatorImport(i))) { + continue; + } + + // all imported symbols are data persistence operators, change import path + if (importSpecifiers.every((i) => isOperatorImport(i))) { + const IMPORT_PATH_SELECTOR = `${NX_ANGULAR_IMPORT_SELECTOR} > StringLiteral`; + const importPathNode = tsquery(importDeclaration, IMPORT_PATH_SELECTOR, { + visitAllChildren: true, + }); + recorder.replace(importPathNode[0], `'${newImportPath}'`); + + continue; + } + + // mixed imports, split data persistence operators to a separate import + const operatorImportSpecifiers: string[] = []; + for (const importSpecifier of importSpecifiers) { + if (isOperatorImport(importSpecifier)) { + operatorImportSpecifiers.push(importSpecifier.getText()); + recorder.remove( + importSpecifier.getStart(), + importSpecifier.getEnd() + + (hasTrailingComma(recorder.originalContent, importSpecifier) + ? 1 + : 0) + ); + } + } + + recorder.insertLeft( + importDeclaration.getStart(), + `import { ${operatorImportSpecifiers.join( + ', ' + )} } from '${newImportPath}';` + ); + } + + if (recorder.hasChanged()) { + recorder.applyChanges(); + return true; + } + + return false; +} + +function hasTrailingComma(content: string, node: Node): boolean { + return content[node.getEnd()] === ','; +} + +function isOperatorImport(importSpecifier: ImportSpecifier): boolean { + return dataPersistenceOperators.includes( + getOriginalIdentifierTextFromImportSpecifier(importSpecifier) + ); +} + +function getOriginalIdentifierTextFromImportSpecifier( + importSpecifier: ImportSpecifier +): string { + const children = importSpecifier.getChildren(); + if (!children.length) { + return importSpecifier.getText(); + } + + return children[0].getText(); +} + +function addNgrxRouterStoreIfNotInstalled(tree: Tree): void { + const { dependencies, devDependencies } = readJson(tree, 'package.json'); + if ( + dependencies?.['@ngrx/router-store'] || + devDependencies?.['@ngrx/router-store'] + ) { + return; + } + + addDependenciesToPackageJson(tree, { '@ngrx/router-store': ngrxVersion }, {}); +} + +function filterFilesWithNxAngularDep(files: FileData[]): FileData[] { + const filteredFiles: FileData[] = []; + + for (const file of files) { + if ( + file.dependencies?.some((dep) => + angularPluginTargetNames.includes(dep.target) + ) + ) { + filteredFiles.push(file); + } + } + + return filteredFiles; +} diff --git a/packages/angular/src/utils/versions.ts b/packages/angular/src/utils/versions.ts index dd398a886668ba..72815307152c1c 100644 --- a/packages/angular/src/utils/versions.ts +++ b/packages/angular/src/utils/versions.ts @@ -3,7 +3,7 @@ export const nxVersion = require('../../package.json').version; export const angularVersion = '~16.0.0'; export const angularDevkitVersion = '~16.0.0'; export const ngPackagrVersion = '~16.0.0'; -export const ngrxVersion = '~15.3.0'; +export const ngrxVersion = '~16.0.0-rc.0'; export const rxjsVersion = '~7.8.0'; export const zoneJsVersion = '~0.13.0'; export const angularJsVersion = '1.7.9'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96893408a431d3..519c2178b78e3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,14 +233,14 @@ devDependencies: specifier: ^9.0.0 version: 9.1.6(@nestjs/common@9.1.6)(@nestjs/core@9.1.6)(@nestjs/platform-express@9.1.6) '@ngrx/effects': - specifier: ~15.3.0 - version: 15.3.0(@angular/core@16.0.0)(@ngrx/store@15.3.0)(rxjs@7.8.1) + specifier: ~16.0.0-rc.0 + version: 16.0.0-rc.0(@angular/core@16.0.0)(@ngrx/store@16.0.0-rc.0)(rxjs@7.8.1) '@ngrx/router-store': - specifier: ~15.3.0 - version: 15.3.0(@angular/common@16.0.0)(@angular/core@16.0.0)(@angular/router@16.0.0)(@ngrx/store@15.3.0)(rxjs@7.8.1) + specifier: ~16.0.0-rc.0 + version: 16.0.0-rc.0(@angular/common@16.0.0)(@angular/core@16.0.0)(@angular/router@16.0.0)(@ngrx/store@16.0.0-rc.0)(rxjs@7.8.1) '@ngrx/store': - specifier: ~15.3.0 - version: 15.3.0(@angular/core@16.0.0)(rxjs@7.8.1) + specifier: ~16.0.0-rc.0 + version: 16.0.0-rc.0(@angular/core@16.0.0)(rxjs@7.8.1) '@nguniversal/builders': specifier: ~16.0.0 version: 16.0.0(@angular-devkit/build-angular@16.0.0)(@angular/common@16.0.0)(@angular/core@16.0.0)(@types/express@4.17.14)(chokidar@3.5.3)(typescript@5.0.2) @@ -4868,40 +4868,40 @@ packages: requiresBuild: true optional: true - /@ngrx/effects@15.3.0(@angular/core@16.0.0)(@ngrx/store@15.3.0)(rxjs@7.8.1): - resolution: {integrity: sha512-L+Ie4XFrzYBJOV7hNQvR5hUvG1PSCDd6niwOOJg5nm9zEjSnAxveJ/a3B52pRwge6EYOnrQne97jyArxOzPCJA==} + /@ngrx/effects@16.0.0-rc.0(@angular/core@16.0.0)(@ngrx/store@16.0.0-rc.0)(rxjs@7.8.1): + resolution: {integrity: sha512-5mj6gH0haASkxuQLbnkikrhVWSGbnOWCBNnwjptshgbomumvzW0M6ApB5/Y7T7HwDteWtosEJi9f3GLDCEng5g==} peerDependencies: - '@angular/core': ^15.0.0 - '@ngrx/store': 15.3.0 + '@angular/core': ^16.0.0 + '@ngrx/store': 16.0.0-rc.0 rxjs: ^6.5.3 || ^7.5.0 dependencies: '@angular/core': 16.0.0(rxjs@7.8.1)(zone.js@0.13.0) - '@ngrx/store': 15.3.0(@angular/core@16.0.0)(rxjs@7.8.1) + '@ngrx/store': 16.0.0-rc.0(@angular/core@16.0.0)(rxjs@7.8.1) rxjs: 7.8.1 tslib: 2.5.0 dev: true - /@ngrx/router-store@15.3.0(@angular/common@16.0.0)(@angular/core@16.0.0)(@angular/router@16.0.0)(@ngrx/store@15.3.0)(rxjs@7.8.1): - resolution: {integrity: sha512-rAaKm6oToXF9pj/IRwsEdv/EYgQscHBfDpiAmPufFwRi/nM/NSfxtV0viZLyOw4buZi0xFwucd3aTFtaqY//vQ==} + /@ngrx/router-store@16.0.0-rc.0(@angular/common@16.0.0)(@angular/core@16.0.0)(@angular/router@16.0.0)(@ngrx/store@16.0.0-rc.0)(rxjs@7.8.1): + resolution: {integrity: sha512-9CxERP0UG9E6p0FLNLqmHHXtBcZlJ2jHFt5SM06cq/OEiYRBFRqFRIL2yHHayc9QYeTA2iIlgfczbE9u8sQywQ==} peerDependencies: - '@angular/common': ^15.0.0 - '@angular/core': ^15.0.0 - '@angular/router': ^15.0.0 - '@ngrx/store': 15.3.0 + '@angular/common': ^16.0.0 + '@angular/core': ^16.0.0 + '@angular/router': ^16.0.0 + '@ngrx/store': 16.0.0-rc.0 rxjs: ^6.5.3 || ^7.5.0 dependencies: '@angular/common': 16.0.0(@angular/core@16.0.0)(rxjs@7.8.1) '@angular/core': 16.0.0(rxjs@7.8.1)(zone.js@0.13.0) '@angular/router': 16.0.0(@angular/common@16.0.0)(@angular/core@16.0.0)(@angular/platform-browser@16.0.0)(rxjs@7.8.1) - '@ngrx/store': 15.3.0(@angular/core@16.0.0)(rxjs@7.8.1) + '@ngrx/store': 16.0.0-rc.0(@angular/core@16.0.0)(rxjs@7.8.1) rxjs: 7.8.1 tslib: 2.5.0 dev: true - /@ngrx/store@15.3.0(@angular/core@16.0.0)(rxjs@7.8.1): - resolution: {integrity: sha512-8cd0zWkOZ3TedDQHyOzUxZD1HHa0fU8fgzVX/2eIq6wmnleUxHVOKSJvA+DdE4GRoryFqVhAp17L1r5eC2QYHA==} + /@ngrx/store@16.0.0-rc.0(@angular/core@16.0.0)(rxjs@7.8.1): + resolution: {integrity: sha512-Uqh58OJLH8o8TDFRPawG6oCjdfgT4TcQM33zR2Ii5dQlvkcuZdNm5zVNGCV3EFdc6yKF+QI3xto2Br/sL09aVg==} peerDependencies: - '@angular/core': ^15.0.0 + '@angular/core': ^16.0.0 rxjs: ^6.5.3 || ^7.5.0 dependencies: '@angular/core': 16.0.0(rxjs@7.8.1)(zone.js@0.13.0)