Skip to content

Commit d264c56

Browse files
feat(effects): add migrator for concatLatestFrom (#4311)
Closes #4262
1 parent 62f3971 commit d264c56

File tree

3 files changed

+304
-0
lines changed

3 files changed

+304
-0
lines changed
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import {
2+
SchematicTestRunner,
3+
UnitTestTree,
4+
} from '@angular-devkit/schematics/testing';
5+
import { createWorkspace } from '@ngrx/schematics-core/testing';
6+
import { tags } from '@angular-devkit/core';
7+
import * as path from 'path';
8+
9+
describe('Effects Migration to 18.0.0-beta', () => {
10+
const collectionPath = path.join(__dirname, '../migration.json');
11+
const schematicRunner = new SchematicTestRunner('schematics', collectionPath);
12+
13+
let appTree: UnitTestTree;
14+
15+
beforeEach(async () => {
16+
appTree = await createWorkspace(schematicRunner, appTree);
17+
});
18+
19+
const verifySchematic = async (input: string, output: string) => {
20+
appTree.create('main.ts', input);
21+
22+
const tree = await schematicRunner.runSchematic(
23+
`ngrx-effects-migration-18-beta`,
24+
{},
25+
appTree
26+
);
27+
28+
const actual = tree.readContent('main.ts');
29+
30+
expect(actual).toBe(output);
31+
};
32+
33+
describe('replacements', () => {
34+
it('should replace the import', async () => {
35+
const input = tags.stripIndent`
36+
import { concatLatestFrom } from '@ngrx/effects';
37+
38+
@Injectable()
39+
export class SomeEffects {
40+
41+
}
42+
`;
43+
const output = tags.stripIndent`
44+
import { concatLatestFrom } from '@ngrx/operators';
45+
46+
@Injectable()
47+
export class SomeEffects {
48+
49+
}
50+
`;
51+
52+
await verifySchematic(input, output);
53+
});
54+
55+
it('should also work with " in imports', async () => {
56+
const input = tags.stripIndent`
57+
import { concatLatestFrom } from "@ngrx/effects";
58+
59+
@Injectable()
60+
export class SomeEffects {
61+
62+
}
63+
`;
64+
const output = tags.stripIndent`
65+
import { concatLatestFrom } from '@ngrx/operators';
66+
67+
@Injectable()
68+
export class SomeEffects {
69+
70+
}
71+
`;
72+
await verifySchematic(input, output);
73+
});
74+
75+
it('should replace if multiple imports are inside an import statement', async () => {
76+
const input = tags.stripIndent`
77+
import { Actions, concatLatestFrom } from '@ngrx/effects';
78+
79+
@Injectable()
80+
export class SomeEffects {
81+
actions$ = inject(Actions);
82+
83+
}
84+
`;
85+
const output = tags.stripIndent`
86+
import { Actions } from '@ngrx/effects';
87+
import { concatLatestFrom } from '@ngrx/operators';
88+
89+
@Injectable()
90+
export class SomeEffects {
91+
actions$ = inject(Actions);
92+
93+
}
94+
`;
95+
96+
await verifySchematic(input, output);
97+
});
98+
99+
it('should add concatLatestFrom to existing import', async () => {
100+
const input = tags.stripIndent`
101+
import { Actions, concatLatestFrom } from '@ngrx/effects';
102+
import { tapResponse } from '@ngrx/operators';
103+
104+
@Injectable()
105+
export class SomeEffects {
106+
actions$ = inject(Actions);
107+
108+
}
109+
`;
110+
const output = tags.stripIndent`
111+
import { Actions } from '@ngrx/effects';
112+
import { tapResponse, concatLatestFrom } from '@ngrx/operators';
113+
114+
@Injectable()
115+
export class SomeEffects {
116+
actions$ = inject(Actions);
117+
118+
}
119+
`;
120+
await verifySchematic(input, output);
121+
});
122+
});
123+
124+
it('should add if they are missing', async () => {
125+
const originalPackageJson = JSON.parse(
126+
appTree.readContent('/package.json')
127+
);
128+
expect(originalPackageJson.dependencies['@ngrx/operators']).toBeUndefined();
129+
expect(
130+
originalPackageJson.devDependencies['@ngrx/operators']
131+
).toBeUndefined();
132+
133+
const tree = await schematicRunner.runSchematic(
134+
`ngrx-effects-migration-18-beta`,
135+
{},
136+
appTree
137+
);
138+
139+
const packageJson = JSON.parse(tree.readContent('/package.json'));
140+
expect(packageJson.dependencies['@ngrx/operators']).toBeDefined();
141+
});
142+
});
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import * as ts from 'typescript';
2+
import {
3+
Tree,
4+
Rule,
5+
chain,
6+
SchematicContext,
7+
} from '@angular-devkit/schematics';
8+
import {
9+
addPackageToPackageJson,
10+
Change,
11+
commitChanges,
12+
createReplaceChange,
13+
InsertChange,
14+
visitTSSourceFiles,
15+
} from '../../schematics-core';
16+
import * as os from 'os';
17+
import { createRemoveChange } from '../../schematics-core/utility/change';
18+
19+
export function migrateConcatLatestFromImport(): Rule {
20+
return (tree: Tree, ctx: SchematicContext) => {
21+
const changes: Change[] = [];
22+
addPackageToPackageJson(tree, 'dependencies', '@ngrx/operators', '^18.0.0');
23+
24+
visitTSSourceFiles(tree, (sourceFile) => {
25+
const importDeclarations = new Array<ts.ImportDeclaration>();
26+
getImportDeclarations(sourceFile, importDeclarations);
27+
28+
const effectsImportsAndDeclaration = importDeclarations
29+
.map((effectsImportDeclaration) => {
30+
const effectsImports = getEffectsNamedBinding(
31+
effectsImportDeclaration
32+
);
33+
if (effectsImports) {
34+
return { effectsImports, effectsImportDeclaration };
35+
} else {
36+
return undefined;
37+
}
38+
})
39+
.find(Boolean);
40+
41+
if (!effectsImportsAndDeclaration) {
42+
return;
43+
}
44+
45+
const { effectsImports, effectsImportDeclaration } =
46+
effectsImportsAndDeclaration;
47+
48+
const operatorsImportDeclaration = importDeclarations.find((node) =>
49+
node.moduleSpecifier.getText().includes('@ngrx/operators')
50+
);
51+
52+
const otherEffectsImports = effectsImports.elements
53+
.filter((element) => element.name.getText() !== 'concatLatestFrom')
54+
.map((element) => element.name.getText())
55+
.join(', ');
56+
57+
// Remove `concatLatestFrom` from @ngrx/effects and leave the other imports
58+
if (otherEffectsImports) {
59+
changes.push(
60+
createReplaceChange(
61+
sourceFile,
62+
effectsImportDeclaration,
63+
effectsImportDeclaration.getText(),
64+
`import { ${otherEffectsImports} } from '@ngrx/effects';`
65+
)
66+
);
67+
}
68+
// Remove complete @ngrx/effects import because it contains only `concatLatestFrom`
69+
else {
70+
changes.push(
71+
createRemoveChange(
72+
sourceFile,
73+
effectsImportDeclaration,
74+
effectsImportDeclaration.getStart(),
75+
effectsImportDeclaration.getEnd() + 1
76+
)
77+
);
78+
}
79+
80+
let importAppendedInExistingDeclaration = false;
81+
if (operatorsImportDeclaration?.importClause?.namedBindings) {
82+
const bindings = operatorsImportDeclaration.importClause.namedBindings;
83+
if (ts.isNamedImports(bindings)) {
84+
// Add import to existing @ngrx/operators
85+
const updatedImports = [
86+
...bindings.elements.map((element) => element.name.getText()),
87+
'concatLatestFrom',
88+
];
89+
const newOperatorsImport = `import { ${updatedImports.join(
90+
', '
91+
)} } from '@ngrx/operators';`;
92+
changes.push(
93+
createReplaceChange(
94+
sourceFile,
95+
operatorsImportDeclaration,
96+
operatorsImportDeclaration.getText(),
97+
newOperatorsImport
98+
)
99+
);
100+
importAppendedInExistingDeclaration = true;
101+
}
102+
}
103+
104+
if (!importAppendedInExistingDeclaration) {
105+
// Add new @ngrx/operators import line
106+
const newOperatorsImport = `import { concatLatestFrom } from '@ngrx/operators';`;
107+
changes.push(
108+
new InsertChange(
109+
sourceFile.fileName,
110+
effectsImportDeclaration.getEnd() + 1,
111+
`${newOperatorsImport}${os.EOL}`
112+
)
113+
);
114+
}
115+
116+
commitChanges(tree, sourceFile.fileName, changes);
117+
118+
if (changes.length) {
119+
ctx.logger.info(
120+
`[@ngrx/effects] Updated concatLatestFrom to import from '@ngrx/operators'`
121+
);
122+
}
123+
});
124+
};
125+
}
126+
127+
function getImportDeclarations(
128+
node: ts.Node,
129+
imports: ts.ImportDeclaration[]
130+
): void {
131+
if (ts.isImportDeclaration(node)) {
132+
imports.push(node);
133+
}
134+
135+
ts.forEachChild(node, (childNode) =>
136+
getImportDeclarations(childNode, imports)
137+
);
138+
}
139+
140+
function getEffectsNamedBinding(
141+
node: ts.ImportDeclaration
142+
): ts.NamedImports | null {
143+
const namedBindings = node?.importClause?.namedBindings;
144+
if (
145+
node.moduleSpecifier.getText().includes('@ngrx/effects') &&
146+
namedBindings &&
147+
ts.isNamedImports(namedBindings)
148+
) {
149+
return namedBindings;
150+
}
151+
152+
return null;
153+
}
154+
155+
export default function (): Rule {
156+
return chain([migrateConcatLatestFromImport()]);
157+
}

modules/effects/migrations/migration.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
"description": "The road to v15 beta",
2121
"version": "15-beta",
2222
"factory": "./15_0_0-beta/index"
23+
},
24+
"ngrx-effects-migration-18-beta": {
25+
"description": "As of NgRx v18, the `concatLatestFrom` import has been removed from `@ngrx/effects` in favor of the `@ngrx/operators` package.",
26+
"version": "18-beta",
27+
"factory": "./18_0_0-beta/index"
2328
}
2429
}
2530
}

0 commit comments

Comments
 (0)