Skip to content

Commit

Permalink
feat(store): add TypedAction migration (#4325)
Browse files Browse the repository at this point in the history
  • Loading branch information
timdeschryver committed May 20, 2024
1 parent 79a789d commit f76a401
Show file tree
Hide file tree
Showing 3 changed files with 341 additions and 0 deletions.
135 changes: 135 additions & 0 deletions modules/store/migrations/18_0_0-beta/index.spec.ts
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);
});
});
});
201 changes: 201 additions & 0 deletions modules/store/migrations/18_0_0-beta/index.ts
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(),
]);
}
5 changes: 5 additions & 0 deletions modules/store/migrations/migration.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
"description": "The road to v16.0-beta",
"version": "16.0.0-beta",
"factory": "./16_0_0-beta/index"
},
"ngrx-store-migration-18-beta": {
"description": "As of NgRx v18, the `TypedAction` has been removed in favor of `Action`.",
"version": "18-beta",
"factory": "./18_0_0-beta/index"
}
}
}

0 comments on commit f76a401

Please sign in to comment.