Skip to content

Commit

Permalink
feat(component-store): add migrator for tapResponse
Browse files Browse the repository at this point in the history
Migrates any `import` of `tapResponse` from `@ngrx/component-store`
to `@ngrx/operators`.

Fixes ngrx#4261
  • Loading branch information
rainerhahnekamp committed May 8, 2024
1 parent d264c56 commit aafeb36
Show file tree
Hide file tree
Showing 3 changed files with 302 additions and 1 deletion.
138 changes: 138 additions & 0 deletions modules/component-store/migrations/18_0_0-beta/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
157 changes: 157 additions & 0 deletions modules/component-store/migrations/18_0_0-beta/index.ts
Original file line number Diff line number Diff line change
@@ -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<ts.ImportDeclaration>();
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()]);
}
8 changes: 7 additions & 1 deletion modules/component-store/migrations/migration.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}

0 comments on commit aafeb36

Please sign in to comment.