-
-
Notifications
You must be signed in to change notification settings - Fork 2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(store): add TypedAction migration (#4325)
- Loading branch information
1 parent
79a789d
commit f76a401
Showing
3 changed files
with
341 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
import { | ||
SchematicTestRunner, | ||
UnitTestTree, | ||
} from '@angular-devkit/schematics/testing'; | ||
import { createWorkspace } from '@ngrx/schematics-core/testing'; | ||
import * as path from 'path'; | ||
import { tags } from '@angular-devkit/core'; | ||
|
||
describe('Store 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); | ||
appTree.create( | ||
'other.ts', | ||
`const action: TypedAction<'[SOURCE] Event'> = { type: '[SOURCE] Event' };` | ||
); | ||
|
||
const tree = await schematicRunner.runSchematic( | ||
`ngrx-store-migration-18-beta`, | ||
{}, | ||
appTree | ||
); | ||
|
||
const actual = tree.readContent('main.ts'); | ||
expect(actual).toBe(output); | ||
|
||
const other = tree.readContent('other.ts'); | ||
expect(other).toBe( | ||
`const action: TypedAction<'[SOURCE] Event'> = { type: '[SOURCE] Event' };` | ||
); | ||
}; | ||
|
||
describe('replacements', () => { | ||
it('should replace the import', async () => { | ||
const input = tags.stripIndent` | ||
import { TypedAction } from '@ngrx/store/src/models'; | ||
const action: TypedAction<'[SOURCE] Event'> = { type: '[SOURCE] Event' }; | ||
export type ActionCreatorWithOptionalProps<T> = T extends undefined | ||
? ActionCreator<string, () => TypedAction<string>> | ||
: ActionCreator< | ||
string, | ||
(props: T & NotAllowedCheck<T & object>) => T & TypedAction<string> | ||
>; | ||
class Fixture { | ||
errorHandler(input?: (payload?: any) => TypedAction<any>): Action | never {} | ||
}`; | ||
const output = tags.stripIndent` | ||
import { Action } from '@ngrx/store'; | ||
const action: Action<'[SOURCE] Event'> = { type: '[SOURCE] Event' }; | ||
export type ActionCreatorWithOptionalProps<T> = T extends undefined | ||
? ActionCreator<string, () => Action<string>> | ||
: ActionCreator< | ||
string, | ||
(props: T & NotAllowedCheck<T & object>) => T & Action<string> | ||
>; | ||
class Fixture { | ||
errorHandler(input?: (payload?: any) => Action<any>): Action | never {} | ||
}`; | ||
|
||
await verifySchematic(input, output); | ||
}); | ||
|
||
it('should also work with " in imports', async () => { | ||
const input = tags.stripIndent` | ||
import { TypedAction } from "@ngrx/store/src/models"; | ||
const action: TypedAction<'[SOURCE] Event'> = { type: '[SOURCE] Event' }; | ||
`; | ||
const output = tags.stripIndent` | ||
import { Action } from '@ngrx/store'; | ||
const action: Action<'[SOURCE] Event'> = { type: '[SOURCE] Event' }; | ||
`; | ||
await verifySchematic(input, output); | ||
}); | ||
|
||
it('should replace if multiple imports are inside an import statement', async () => { | ||
const input = tags.stripIndent` | ||
import { TypedAction, ActionReducer } from '@ngrx/store/src/models'; | ||
const action: TypedAction<'[SOURCE] Event'> = { type: '[SOURCE] Event' }; | ||
`; | ||
const output = tags.stripIndent` | ||
import { ActionReducer } from '@ngrx/store/src/models'; | ||
import { Action } from '@ngrx/store'; | ||
const action: Action<'[SOURCE] Event'> = { type: '[SOURCE] Event' }; | ||
`; | ||
|
||
await verifySchematic(input, output); | ||
}); | ||
|
||
it('should add Action to existing import', async () => { | ||
const input = tags.stripIndent` | ||
import { TypedAction } from '@ngrx/store/src/models'; | ||
import { createAction } from '@ngrx/store'; | ||
const action: TypedAction<'[SOURCE] Event'> = { type: '[SOURCE] Event' }; | ||
`; | ||
const output = tags.stripIndent` | ||
import { createAction, Action } from '@ngrx/store'; | ||
const action: Action<'[SOURCE] Event'> = { type: '[SOURCE] Event' }; | ||
`; | ||
await verifySchematic(input, output); | ||
}); | ||
|
||
it('should not add Action if already exists', async () => { | ||
const input = tags.stripIndent` | ||
import { TypedAction } from '@ngrx/store/src/models'; | ||
import { Action } from '@ngrx/store'; | ||
const action: TypedAction<'[SOURCE] Event'> = { type: '[SOURCE] Event' }; | ||
`; | ||
const output = tags.stripIndent` | ||
import { Action } from '@ngrx/store'; | ||
const action: Action<'[SOURCE] Event'> = { type: '[SOURCE] Event' }; | ||
`; | ||
await verifySchematic(input, output); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,201 @@ | ||
import * as ts from 'typescript'; | ||
import { | ||
Tree, | ||
Rule, | ||
chain, | ||
SchematicContext, | ||
} from '@angular-devkit/schematics'; | ||
import { | ||
Change, | ||
commitChanges, | ||
createReplaceChange, | ||
InsertChange, | ||
visitTSSourceFiles, | ||
} from '../../schematics-core'; | ||
import { createRemoveChange } from '../../schematics-core/utility/change'; | ||
|
||
const storeModelsPath = '@ngrx/store/src/models'; | ||
const filesWithChanges: string[] = []; | ||
|
||
export function migrateStoreTypedAction(): Rule { | ||
return (tree: Tree, ctx: SchematicContext) => { | ||
visitTSSourceFiles(tree, (sourceFile) => { | ||
const changes: Change[] = []; | ||
|
||
const importDeclarations = new Array<ts.ImportDeclaration>(); | ||
getImportDeclarations(sourceFile, importDeclarations); | ||
|
||
const storeModelsImportsAndDeclaration = importDeclarations | ||
.map((storeModelsImportDeclaration) => { | ||
const storeModelsImports = getStoreModelsNamedBindings( | ||
storeModelsImportDeclaration | ||
); | ||
if (storeModelsImports) { | ||
return { storeModelsImports, storeModelsImportDeclaration }; | ||
} else { | ||
return undefined; | ||
} | ||
}) | ||
.find(Boolean); | ||
|
||
if (!storeModelsImportsAndDeclaration) { | ||
return; | ||
} | ||
|
||
const { storeModelsImports, storeModelsImportDeclaration } = | ||
storeModelsImportsAndDeclaration; | ||
|
||
const storeImportDeclaration = importDeclarations.find( | ||
(node) => | ||
node.moduleSpecifier.getText().includes('@ngrx/store') && | ||
!node.moduleSpecifier.getText().includes('@ngrx/store/') | ||
); | ||
|
||
const otherStoreModelImports = storeModelsImports.elements | ||
.filter((element) => element.name.getText() !== 'TypedAction') | ||
.map((element) => element.name.getText()) | ||
.join(', '); | ||
|
||
// Remove `TypedAction` from @ngrx/store/src/models and leave the other imports | ||
if (otherStoreModelImports) { | ||
changes.push( | ||
createReplaceChange( | ||
sourceFile, | ||
storeModelsImportDeclaration, | ||
storeModelsImportDeclaration.getText(), | ||
`import { ${otherStoreModelImports} } from '${storeModelsPath}';` | ||
) | ||
); | ||
} | ||
// Remove complete import because it's empty | ||
else { | ||
changes.push( | ||
createRemoveChange( | ||
sourceFile, | ||
storeModelsImportDeclaration, | ||
storeModelsImportDeclaration.getStart(), | ||
storeModelsImportDeclaration.getEnd() + 1 | ||
) | ||
); | ||
} | ||
|
||
let importAppendedInExistingDeclaration = false; | ||
if (storeImportDeclaration?.importClause?.namedBindings) { | ||
const bindings = storeImportDeclaration.importClause.namedBindings; | ||
if (ts.isNamedImports(bindings)) { | ||
// Add import to existing @ngrx/operators | ||
const updatedImports = new Set([ | ||
...bindings.elements.map((element) => element.name.getText()), | ||
'Action', | ||
]); | ||
const importStatement = `import { ${[...updatedImports].join( | ||
', ' | ||
)} } from '@ngrx/store';`; | ||
changes.push( | ||
createReplaceChange( | ||
sourceFile, | ||
storeImportDeclaration, | ||
storeImportDeclaration.getText(), | ||
importStatement | ||
) | ||
); | ||
importAppendedInExistingDeclaration = true; | ||
} | ||
} | ||
|
||
if (!importAppendedInExistingDeclaration) { | ||
// Add new @ngrx/operators import line | ||
const importStatement = `import { Action } from '@ngrx/store';`; | ||
changes.push( | ||
new InsertChange( | ||
sourceFile.fileName, | ||
storeModelsImportDeclaration.getEnd() + 1, | ||
`${importStatement}\n` | ||
) | ||
); | ||
} | ||
|
||
commitChanges(tree, sourceFile.fileName, changes); | ||
|
||
if (changes.length) { | ||
filesWithChanges.push(sourceFile.fileName); | ||
ctx.logger.info( | ||
`[@ngrx/store] ${sourceFile.fileName}: Replaced TypedAction to Action` | ||
); | ||
} | ||
}); | ||
}; | ||
} | ||
|
||
export function migrateStoreTypedActionReferences(): Rule { | ||
return (tree: Tree, _ctx: SchematicContext) => { | ||
visitTSSourceFiles(tree, (sourceFile) => { | ||
if (!filesWithChanges.includes(sourceFile.fileName)) { | ||
return; | ||
} | ||
const changes: Change[] = []; | ||
const typedActionIdentifiers = new Array<ts.Identifier>(); | ||
getTypedActionUsages(sourceFile, typedActionIdentifiers); | ||
|
||
typedActionIdentifiers.forEach((identifier) => { | ||
changes.push( | ||
createReplaceChange( | ||
sourceFile, | ||
identifier, | ||
identifier.getText(), | ||
'Action' | ||
) | ||
); | ||
}); | ||
commitChanges(tree, sourceFile.fileName, changes); | ||
}); | ||
}; | ||
} | ||
|
||
function getImportDeclarations( | ||
node: ts.Node, | ||
imports: ts.ImportDeclaration[] | ||
): void { | ||
if (ts.isImportDeclaration(node)) { | ||
imports.push(node); | ||
} | ||
|
||
ts.forEachChild(node, (childNode) => | ||
getImportDeclarations(childNode, imports) | ||
); | ||
} | ||
|
||
function getTypedActionUsages( | ||
node: ts.Node, | ||
nodeIdentifiers: ts.Identifier[] | ||
): void { | ||
if (ts.isIdentifier(node) && node.getText() === 'TypedAction') { | ||
nodeIdentifiers.push(node); | ||
} | ||
|
||
ts.forEachChild(node, (childNode) => | ||
getTypedActionUsages(childNode, nodeIdentifiers) | ||
); | ||
} | ||
|
||
function getStoreModelsNamedBindings( | ||
node: ts.ImportDeclaration | ||
): ts.NamedImports | null { | ||
const namedBindings = node?.importClause?.namedBindings; | ||
if ( | ||
node.moduleSpecifier.getText().includes(storeModelsPath) && | ||
namedBindings && | ||
ts.isNamedImports(namedBindings) | ||
) { | ||
return namedBindings; | ||
} | ||
|
||
return null; | ||
} | ||
|
||
export default function (): Rule { | ||
return chain([ | ||
migrateStoreTypedAction(), | ||
migrateStoreTypedActionReferences(), | ||
]); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters