Skip to content

Commit f76a401

Browse files
feat(store): add TypedAction migration (#4325)
1 parent 79a789d commit f76a401

File tree

3 files changed

+341
-0
lines changed

3 files changed

+341
-0
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import {
2+
SchematicTestRunner,
3+
UnitTestTree,
4+
} from '@angular-devkit/schematics/testing';
5+
import { createWorkspace } from '@ngrx/schematics-core/testing';
6+
import * as path from 'path';
7+
import { tags } from '@angular-devkit/core';
8+
9+
describe('Store 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+
appTree.create(
22+
'other.ts',
23+
`const action: TypedAction<'[SOURCE] Event'> = { type: '[SOURCE] Event' };`
24+
);
25+
26+
const tree = await schematicRunner.runSchematic(
27+
`ngrx-store-migration-18-beta`,
28+
{},
29+
appTree
30+
);
31+
32+
const actual = tree.readContent('main.ts');
33+
expect(actual).toBe(output);
34+
35+
const other = tree.readContent('other.ts');
36+
expect(other).toBe(
37+
`const action: TypedAction<'[SOURCE] Event'> = { type: '[SOURCE] Event' };`
38+
);
39+
};
40+
41+
describe('replacements', () => {
42+
it('should replace the import', async () => {
43+
const input = tags.stripIndent`
44+
import { TypedAction } from '@ngrx/store/src/models';
45+
46+
const action: TypedAction<'[SOURCE] Event'> = { type: '[SOURCE] Event' };
47+
48+
export type ActionCreatorWithOptionalProps<T> = T extends undefined
49+
? ActionCreator<string, () => TypedAction<string>>
50+
: ActionCreator<
51+
string,
52+
(props: T & NotAllowedCheck<T & object>) => T & TypedAction<string>
53+
>;
54+
55+
class Fixture {
56+
errorHandler(input?: (payload?: any) => TypedAction<any>): Action | never {}
57+
}`;
58+
const output = tags.stripIndent`
59+
import { Action } from '@ngrx/store';
60+
61+
const action: Action<'[SOURCE] Event'> = { type: '[SOURCE] Event' };
62+
63+
export type ActionCreatorWithOptionalProps<T> = T extends undefined
64+
? ActionCreator<string, () => Action<string>>
65+
: ActionCreator<
66+
string,
67+
(props: T & NotAllowedCheck<T & object>) => T & Action<string>
68+
>;
69+
70+
class Fixture {
71+
errorHandler(input?: (payload?: any) => Action<any>): Action | never {}
72+
}`;
73+
74+
await verifySchematic(input, output);
75+
});
76+
77+
it('should also work with " in imports', async () => {
78+
const input = tags.stripIndent`
79+
import { TypedAction } from "@ngrx/store/src/models";
80+
const action: TypedAction<'[SOURCE] Event'> = { type: '[SOURCE] Event' };
81+
`;
82+
const output = tags.stripIndent`
83+
import { Action } from '@ngrx/store';
84+
const action: Action<'[SOURCE] Event'> = { type: '[SOURCE] Event' };
85+
`;
86+
await verifySchematic(input, output);
87+
});
88+
89+
it('should replace if multiple imports are inside an import statement', async () => {
90+
const input = tags.stripIndent`
91+
import { TypedAction, ActionReducer } from '@ngrx/store/src/models';
92+
93+
const action: TypedAction<'[SOURCE] Event'> = { type: '[SOURCE] Event' };
94+
`;
95+
const output = tags.stripIndent`
96+
import { ActionReducer } from '@ngrx/store/src/models';
97+
import { Action } from '@ngrx/store';
98+
99+
const action: Action<'[SOURCE] Event'> = { type: '[SOURCE] Event' };
100+
`;
101+
102+
await verifySchematic(input, output);
103+
});
104+
105+
it('should add Action to existing import', async () => {
106+
const input = tags.stripIndent`
107+
import { TypedAction } from '@ngrx/store/src/models';
108+
import { createAction } from '@ngrx/store';
109+
110+
const action: TypedAction<'[SOURCE] Event'> = { type: '[SOURCE] Event' };
111+
`;
112+
const output = tags.stripIndent`
113+
import { createAction, Action } from '@ngrx/store';
114+
115+
const action: Action<'[SOURCE] Event'> = { type: '[SOURCE] Event' };
116+
`;
117+
await verifySchematic(input, output);
118+
});
119+
120+
it('should not add Action if already exists', async () => {
121+
const input = tags.stripIndent`
122+
import { TypedAction } from '@ngrx/store/src/models';
123+
import { Action } from '@ngrx/store';
124+
125+
const action: TypedAction<'[SOURCE] Event'> = { type: '[SOURCE] Event' };
126+
`;
127+
const output = tags.stripIndent`
128+
import { Action } from '@ngrx/store';
129+
130+
const action: Action<'[SOURCE] Event'> = { type: '[SOURCE] Event' };
131+
`;
132+
await verifySchematic(input, output);
133+
});
134+
});
135+
});
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import * as ts from 'typescript';
2+
import {
3+
Tree,
4+
Rule,
5+
chain,
6+
SchematicContext,
7+
} from '@angular-devkit/schematics';
8+
import {
9+
Change,
10+
commitChanges,
11+
createReplaceChange,
12+
InsertChange,
13+
visitTSSourceFiles,
14+
} from '../../schematics-core';
15+
import { createRemoveChange } from '../../schematics-core/utility/change';
16+
17+
const storeModelsPath = '@ngrx/store/src/models';
18+
const filesWithChanges: string[] = [];
19+
20+
export function migrateStoreTypedAction(): Rule {
21+
return (tree: Tree, ctx: SchematicContext) => {
22+
visitTSSourceFiles(tree, (sourceFile) => {
23+
const changes: Change[] = [];
24+
25+
const importDeclarations = new Array<ts.ImportDeclaration>();
26+
getImportDeclarations(sourceFile, importDeclarations);
27+
28+
const storeModelsImportsAndDeclaration = importDeclarations
29+
.map((storeModelsImportDeclaration) => {
30+
const storeModelsImports = getStoreModelsNamedBindings(
31+
storeModelsImportDeclaration
32+
);
33+
if (storeModelsImports) {
34+
return { storeModelsImports, storeModelsImportDeclaration };
35+
} else {
36+
return undefined;
37+
}
38+
})
39+
.find(Boolean);
40+
41+
if (!storeModelsImportsAndDeclaration) {
42+
return;
43+
}
44+
45+
const { storeModelsImports, storeModelsImportDeclaration } =
46+
storeModelsImportsAndDeclaration;
47+
48+
const storeImportDeclaration = importDeclarations.find(
49+
(node) =>
50+
node.moduleSpecifier.getText().includes('@ngrx/store') &&
51+
!node.moduleSpecifier.getText().includes('@ngrx/store/')
52+
);
53+
54+
const otherStoreModelImports = storeModelsImports.elements
55+
.filter((element) => element.name.getText() !== 'TypedAction')
56+
.map((element) => element.name.getText())
57+
.join(', ');
58+
59+
// Remove `TypedAction` from @ngrx/store/src/models and leave the other imports
60+
if (otherStoreModelImports) {
61+
changes.push(
62+
createReplaceChange(
63+
sourceFile,
64+
storeModelsImportDeclaration,
65+
storeModelsImportDeclaration.getText(),
66+
`import { ${otherStoreModelImports} } from '${storeModelsPath}';`
67+
)
68+
);
69+
}
70+
// Remove complete import because it's empty
71+
else {
72+
changes.push(
73+
createRemoveChange(
74+
sourceFile,
75+
storeModelsImportDeclaration,
76+
storeModelsImportDeclaration.getStart(),
77+
storeModelsImportDeclaration.getEnd() + 1
78+
)
79+
);
80+
}
81+
82+
let importAppendedInExistingDeclaration = false;
83+
if (storeImportDeclaration?.importClause?.namedBindings) {
84+
const bindings = storeImportDeclaration.importClause.namedBindings;
85+
if (ts.isNamedImports(bindings)) {
86+
// Add import to existing @ngrx/operators
87+
const updatedImports = new Set([
88+
...bindings.elements.map((element) => element.name.getText()),
89+
'Action',
90+
]);
91+
const importStatement = `import { ${[...updatedImports].join(
92+
', '
93+
)} } from '@ngrx/store';`;
94+
changes.push(
95+
createReplaceChange(
96+
sourceFile,
97+
storeImportDeclaration,
98+
storeImportDeclaration.getText(),
99+
importStatement
100+
)
101+
);
102+
importAppendedInExistingDeclaration = true;
103+
}
104+
}
105+
106+
if (!importAppendedInExistingDeclaration) {
107+
// Add new @ngrx/operators import line
108+
const importStatement = `import { Action } from '@ngrx/store';`;
109+
changes.push(
110+
new InsertChange(
111+
sourceFile.fileName,
112+
storeModelsImportDeclaration.getEnd() + 1,
113+
`${importStatement}\n`
114+
)
115+
);
116+
}
117+
118+
commitChanges(tree, sourceFile.fileName, changes);
119+
120+
if (changes.length) {
121+
filesWithChanges.push(sourceFile.fileName);
122+
ctx.logger.info(
123+
`[@ngrx/store] ${sourceFile.fileName}: Replaced TypedAction to Action`
124+
);
125+
}
126+
});
127+
};
128+
}
129+
130+
export function migrateStoreTypedActionReferences(): Rule {
131+
return (tree: Tree, _ctx: SchematicContext) => {
132+
visitTSSourceFiles(tree, (sourceFile) => {
133+
if (!filesWithChanges.includes(sourceFile.fileName)) {
134+
return;
135+
}
136+
const changes: Change[] = [];
137+
const typedActionIdentifiers = new Array<ts.Identifier>();
138+
getTypedActionUsages(sourceFile, typedActionIdentifiers);
139+
140+
typedActionIdentifiers.forEach((identifier) => {
141+
changes.push(
142+
createReplaceChange(
143+
sourceFile,
144+
identifier,
145+
identifier.getText(),
146+
'Action'
147+
)
148+
);
149+
});
150+
commitChanges(tree, sourceFile.fileName, changes);
151+
});
152+
};
153+
}
154+
155+
function getImportDeclarations(
156+
node: ts.Node,
157+
imports: ts.ImportDeclaration[]
158+
): void {
159+
if (ts.isImportDeclaration(node)) {
160+
imports.push(node);
161+
}
162+
163+
ts.forEachChild(node, (childNode) =>
164+
getImportDeclarations(childNode, imports)
165+
);
166+
}
167+
168+
function getTypedActionUsages(
169+
node: ts.Node,
170+
nodeIdentifiers: ts.Identifier[]
171+
): void {
172+
if (ts.isIdentifier(node) && node.getText() === 'TypedAction') {
173+
nodeIdentifiers.push(node);
174+
}
175+
176+
ts.forEachChild(node, (childNode) =>
177+
getTypedActionUsages(childNode, nodeIdentifiers)
178+
);
179+
}
180+
181+
function getStoreModelsNamedBindings(
182+
node: ts.ImportDeclaration
183+
): ts.NamedImports | null {
184+
const namedBindings = node?.importClause?.namedBindings;
185+
if (
186+
node.moduleSpecifier.getText().includes(storeModelsPath) &&
187+
namedBindings &&
188+
ts.isNamedImports(namedBindings)
189+
) {
190+
return namedBindings;
191+
}
192+
193+
return null;
194+
}
195+
196+
export default function (): Rule {
197+
return chain([
198+
migrateStoreTypedAction(),
199+
migrateStoreTypedActionReferences(),
200+
]);
201+
}

modules/store/migrations/migration.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@
3535
"description": "The road to v16.0-beta",
3636
"version": "16.0.0-beta",
3737
"factory": "./16_0_0-beta/index"
38+
},
39+
"ngrx-store-migration-18-beta": {
40+
"description": "As of NgRx v18, the `TypedAction` has been removed in favor of `Action`.",
41+
"version": "18-beta",
42+
"factory": "./18_0_0-beta/index"
3843
}
3944
}
4045
}

0 commit comments

Comments
 (0)