From 5800cd24ffdead5c03abbe5df7cda35df2593aca Mon Sep 17 00:00:00 2001 From: MKless Date: Wed, 3 Jun 2020 19:33:39 +0200 Subject: [PATCH] feat: add migration script for NgRx 8 creator functions --- schematics/migration/0.20-to-0.21/index.js | 68 +++++ .../migrate-action-creators.actions.ts | 263 ++++++++++++++++++ .../migrate-action-creators.effects.ts | 91 ++++++ .../migrate-action-creators.reducers.ts | 190 +++++++++++++ .../migration/0.20-to-0.21/morph-helpers.ts | 157 +++++++++++ .../migration/0.20-to-0.21/store-migration.ts | 56 ++++ .../checkActionsForCreatorMigrationRule.ts | 33 +++ ...ngrxUseComplexTypeWithActionPayloadRule.ts | 68 ----- tslint-rules/src/ngrxUseOftypeWithTypeRule.ts | 32 --- tslint.json | 26 +- 10 files changed, 882 insertions(+), 102 deletions(-) create mode 100644 schematics/migration/0.20-to-0.21/index.js create mode 100644 schematics/migration/0.20-to-0.21/migrate-action-creators.actions.ts create mode 100644 schematics/migration/0.20-to-0.21/migrate-action-creators.effects.ts create mode 100644 schematics/migration/0.20-to-0.21/migrate-action-creators.reducers.ts create mode 100644 schematics/migration/0.20-to-0.21/morph-helpers.ts create mode 100644 schematics/migration/0.20-to-0.21/store-migration.ts create mode 100644 tslint-rules/src/checkActionsForCreatorMigrationRule.ts delete mode 100644 tslint-rules/src/ngrxUseComplexTypeWithActionPayloadRule.ts delete mode 100644 tslint-rules/src/ngrxUseOftypeWithTypeRule.ts diff --git a/schematics/migration/0.20-to-0.21/index.js b/schematics/migration/0.20-to-0.21/index.js new file mode 100644 index 0000000000..d863fdbf2d --- /dev/null +++ b/schematics/migration/0.20-to-0.21/index.js @@ -0,0 +1,68 @@ +const { execSync, spawnSync } = require('child_process'); +const { Project } = require('ts-morph'); +const { Linter } = require('tslint'); +const { readFileSync } = require('fs'); +const glob = require('glob'); + +console.log('building lint rules'); +execSync('npm run build:tslint-rules', { stdio: 'ignore' }); +console.log('running sanity check'); + +const files = glob.sync('src/**/store/**/*.ts'); + +const linter = new Linter({ + fix: true, + formatter: 'prose', +}); +const lintConfig = { + rules: new Map() + .set('no-star-imports-in-store', true) + .set('force-jsdoc-comments', true) + .set('check-actions-for-creator-migration', true), + rulesDirectory: ['node_modules/intershop-tslint-rules'], + jsRules: new Map(), + extends: [], +}; +files.forEach(sourcePath => { + linter.lint(sourcePath, readFileSync(sourcePath, { encoding: 'utf-8' }), lintConfig); +}); + +const result = linter.getResult(); +if (result.errorCount) { + console.warn(result.output); + process.exit(1); +} + +execSync('npx ts-node ' + process.argv[1] + '/store-migration.ts', { stdio: 'inherit' }); + +const changedFiles = () => + spawnSync('git', ['--no-pager', 'diff', '--name-only']) + .stdout.toString('utf-8') + .split('\n') + .filter(x => !!x && x.endsWith('.ts') && (x.startsWith('src/app/') || x.startsWith('projects/'))) + .sort(); + +const project = new Project({ tsConfigFilePath: 'tsconfig.all.json' }); +changedFiles().forEach(path => { + console.log('post-processing', path); + const sf = project.getSourceFileOrThrow(path); + sf.fixMissingImports(); + sf.fixUnusedIdentifiers(); + sf.formatText({ + indentSize: 2, + indentStyle: 2, + convertTabsToSpaces: true, + }); + sf.saveSync(); + execSync('node scripts/fix-imports ' + path); + try { + execSync('npx prettier --write ' + path); + } catch (err) {} +}); + +if (changedFiles().length) { + console.log('linting -- this will take some time'); + execSync('npm run lint -- --fix --force'); + + execSync('npm run lint -- --fix', { stdio: 'inherit' }); +} diff --git a/schematics/migration/0.20-to-0.21/migrate-action-creators.actions.ts b/schematics/migration/0.20-to-0.21/migrate-action-creators.actions.ts new file mode 100644 index 0000000000..70100d7f32 --- /dev/null +++ b/schematics/migration/0.20-to-0.21/migrate-action-creators.actions.ts @@ -0,0 +1,263 @@ +import { + ClassDeclaration, + EnumDeclaration, + Node, + PropertyAccessExpression, + SourceFile, + SyntaxKind, + TypeAliasDeclaration, + TypeReferenceNode, + UnionTypeNode, + VariableDeclarationKind, +} from 'ts-morph'; + +// tslint:disable:no-console +export class ActionCreatorsActionsMorpher { + constructor(public actionsFile: SourceFile) {} + actionTypes: { [typeName: string]: string }; + + migrateActions() { + if (!this.actionsFile) { + return; + } + console.log('migrating', this.actionsFile.getFilePath()); + if (!this.checkUnmigratedFile()) { + return; + } + console.log('replacing actions...'); + + this.actionsFile.addImportDeclaration({ + moduleSpecifier: 'ish-core/utils/ngrx-creators', + namedImports: ['httpError', 'payload'], + }); + + this.readActionTypes(); + this.replaceActions(); + this.updateGlobalEnumReferences(this.actionsFile.getEnums()[0]); + const actionBundleType = this.actionsFile.getTypeAliases().find(al => /Actions?$/.test(al.getName())); + if (actionBundleType) { + this.updateGlobalTypeAliasReferences(actionBundleType); + } + + // clean up old code + this.actionsFile.getEnums()[0].remove(); + + if (actionBundleType) { + actionBundleType.remove(); + } + this.actionsFile.fixMissingImports(); + } + + /** + * read action types from actions enum and save in this.actionTypes + */ + private readActionTypes() { + console.log(' reading action types...'); + this.actionTypes = this.actionsFile + .getEnums()[0] + .getMembers() + .reduce( + (acc, current) => ({ + ...acc, + [current.getName()]: current.getInitializer().getText(), + }), + {} + ); + console.log(` ${Object.keys(this.actionTypes).length} actions found`); + } + + /** + * replace action class declaration with createAction factory call + */ + private replaceActions() { + console.log(' replacing action classes with creator functions...'); + this.actionsFile.getClasses().forEach(actionClass => { + // retrieve basic action information + const className = actionClass.getName(); + const enumName = (actionClass.getPropertyOrThrow('type').getInitializer() as PropertyAccessExpression).getName(); + const typeString = this.actionTypes[enumName]; + + // get parameter information + let initializer; + if (actionClass.getConstructors().length) { + const payloadParameter = actionClass.getConstructors()[0].getParameter('payload'); + const payloadParameterTypeNode = payloadParameter.getTypeNode(); + if (Node.isTypeLiteralNode(payloadParameterTypeNode)) { + const properties = payloadParameterTypeNode.getMembers().filter(Node.isPropertySignature); + if (properties.some(el => el.getName() === 'error' && el.getTypeNode().getText() === 'HttpError')) { + const params = properties + .filter(el => el.getName() !== 'error') + .map(el => `${el.getName()}: ${el.getTypeNode().getText()}`) + .join(', '); + if (params.length) { + initializer = `createAction(${typeString}, httpError<{${params}}>())`; + } else { + initializer = `createAction(${typeString}, httpError())`; + } + } + } + + if (!initializer) { + initializer = `createAction(${typeString}, payload<${payloadParameter.getTypeNode().getText()}>())`; + } + } + if (!initializer) { + initializer = `createAction(${typeString})`; + } + + // assemble structure object + const createActionStructure = { + isExported: true, + isDefaultExport: false, + hasDeclareKeyword: false, + declarationKind: VariableDeclarationKind.Const, + declarations: [ + { + name: className.replace(/^\w/, c => c.toLowerCase()), + initializer, + type: undefined, + hasExclamationToken: false, + kind: 38, + }, + ], + }; + + this.actionsFile.addVariableStatement(createActionStructure); + + // update references in other files + this.updateGlobalActionReferences(actionClass); + // fix updated files + actionClass + .findReferencesAsNodes() + .map(node => node.getSourceFile()) + .filter((value, index, array) => index === array.indexOf(value)) + .forEach(sf => { + sf.fixMissingImports(); + }); + // remove class from file + actionClass.remove(); + }); + } + + /** + * replaces global references to a given actionClass with createAction calls + * @param actionClass the actionClass to update + */ + private updateGlobalActionReferences(actionClass: ClassDeclaration) { + console.log(` updating references for ${actionClass.getName()}...`); + + // iterate over all actionClass references + let i = 0; + actionClass.findReferencesAsNodes().forEach(reference => { + // exclude tests and the actions file itself + if (reference.getSourceFile() !== this.actionsFile) { + // extract information about the reference + const newExpression = reference.getFirstAncestorByKind(SyntaxKind.NewExpression); + const unionType = reference.getFirstAncestorByKind(SyntaxKind.UnionType); + const callExpression = reference.getFirstAncestorByKind(SyntaxKind.CallExpression); + + // NewExpressions or BinaryExpressions or CallExpressions + if (newExpression) { + // swap new class instantiation to actionCreator call + newExpression.replaceWithText( + actionClass.getName().substr(0, 1).toLowerCase() + newExpression.getText().substr(5) + ); + i++; + return; + } else if (unionType) { + const typesArray = unionType.getTypeNodes().map(type => type.getText().replace(/^\w/, c => c.toLowerCase())); + const returnTypeString = `ReturnType <${typesArray.map(str => `typeof ${str}`).join(' | ')}>`; + unionType.replaceWithText(returnTypeString); + i++; + } else if ( + callExpression && + callExpression + .getArguments() + .filter(arg => arg.getKind() === SyntaxKind.Identifier) + .includes(reference) + ) { + // update action references in call expressions + callExpression + .getArguments() + .filter(arg => arg === reference) + .forEach(arg => arg.replaceWithText(actionClass.getName().replace(/^\w/, c => c.toLowerCase()))); + i++; + } + + // ToDo: maybe update other expressions + } + }); + i > 0 ? console.log(` updated ${i} reference${i > 1 ? 's' : ''}.`) : console.log(' no references found.'); + actionClass.getSourceFile().fixMissingImports(); + } + + /** + * replaces global references to a given enumDeclaration + * @param enumDeclaration the enumDeclaration to update references of + */ + private updateGlobalEnumReferences(enumDeclaration: EnumDeclaration) { + console.log(' updating enum references...'); + let i = 0; + enumDeclaration + .findReferencesAsNodes() + .filter( + ref => + ref.getSourceFile() !== this.actionsFile && + !ref.getSourceFile().getBaseName().includes('reducer.ts') && + !ref.getSourceFile().getBaseName().includes('effects.ts') && + ref.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) === undefined + ) + .forEach(reference => { + const sibling = reference.getParentIfKind(SyntaxKind.PropertyAccessExpression) + ? reference.getParent().getLastChild().getText() + : undefined; + if (sibling) { + reference.getParent().replaceWithText(`${sibling.replace(/^\w/, c => c.toLowerCase())}.type`); + i++; + } + }); + console.log(` updated ${i} reference${i > 1 || i === 0 ? 's' : ''}`); + } + + /** + * replaces global references to a given typeAlias + * @param typeAlias the typeAlias to update references of + */ + private updateGlobalTypeAliasReferences(typeAlias: TypeAliasDeclaration) { + console.log('updating type alias references...'); + // extract types to string array + const types = + typeAlias.getTypeNode().getKind() === SyntaxKind.UnionType + ? (typeAlias.getTypeNode() as UnionTypeNode) + .getTypeNodes() + .map(typeNode => typeNode.getText().replace(/^\w/, c => c.toLowerCase())) + : [ + (typeAlias.getTypeNode() as TypeReferenceNode) + .getTypeName() + .getText() + .replace(/^\w/, c => c.toLowerCase()), + ]; + const typeString = `ReturnType< ${types.map(type => `typeof ${type}`).join(' | ')} >`; + typeAlias + .findReferencesAsNodes() + .filter( + ref => + ref.getSourceFile() !== this.actionsFile && + ref.getFirstAncestorByKind(SyntaxKind.ImportDeclaration) === undefined + ) + .forEach(reference => { + reference.replaceWithText(typeString); + }); + } + + private checkUnmigratedFile(): boolean { + const hasEnum = this.actionsFile.getEnums().length > 0; + const hasClass = this.actionsFile.getClasses().length > 0; + if (!hasEnum || !hasClass) { + console.log('this file is not a valid action file, skipping...'); + return false; + } else { + return true; + } + } +} diff --git a/schematics/migration/0.20-to-0.21/migrate-action-creators.effects.ts b/schematics/migration/0.20-to-0.21/migrate-action-creators.effects.ts new file mode 100644 index 0000000000..f3a443c60e --- /dev/null +++ b/schematics/migration/0.20-to-0.21/migrate-action-creators.effects.ts @@ -0,0 +1,91 @@ +import { CallExpression, SourceFile, SyntaxKind } from 'ts-morph'; + +// tslint:disable: no-console + +export class ActionCreatorsEffectMorpher { + constructor(public effectsFile: SourceFile) {} + + migrateEffects() { + if (!this.effectsFile) { + return; + } + console.log('migrating', this.effectsFile.getFilePath()); + console.log('replacing effects...'); + this.effectsFile + .getClasses()[0] + .getChildrenOfKind(SyntaxKind.PropertyDeclaration) + .filter(property => property.getFirstChildByKind(SyntaxKind.Decorator)) + .forEach(effect => { + // retrieve information from effect + const name = effect.getName(); + const decoratorConfig = effect.getFirstChildByKindOrThrow(SyntaxKind.Decorator).getArguments(); + let logic = effect.getInitializerIfKindOrThrow(SyntaxKind.CallExpression); + + // update effect logic + logic = this.ensurePipeSafety(logic); + logic = this.updateOfType(logic); + + effect.set({ + name, + initializer: + decoratorConfig.length > 0 + ? `createEffect(() => ${logic.getText()}, ${decoratorConfig[0].getText()})` + : `createEffect(() => ${logic.getText()})`, + }); + effect.getDecorators().forEach(d => d.remove()); + }); + this.effectsFile.fixMissingImports(); + } + + private ensurePipeSafety(pipe: CallExpression): CallExpression { + const exps = pipe.getDescendantsOfKind(SyntaxKind.CallExpression); + exps.push(pipe); + exps + .filter(exp => exp.getExpression().getText().includes('pipe') && exp.getArguments().length > 10) + .forEach(pipeExp => { + const args = pipeExp.getArguments(); + let chunks = []; + let i = 0; + while (i < args.length) { + chunks.push(args.slice(i, (i += 10))); + } + chunks = chunks.map(chunk => chunk.map(c => c.getText()).join(', ')); + const newString = `${pipeExp.getExpression().getText()}(${chunks.join(' ).pipe( ')})`; + pipeExp.replaceWithText(newString); + }); + return pipe; + } + /** + * updates ofType calls in given pipe + * @param pipe pipe CallExpression + */ + private updateOfType(pipe: CallExpression): CallExpression { + pipe + // get piped functions and their descendants + .getDescendantsOfKind(SyntaxKind.CallExpression) + .filter(exp => exp.getExpression().getText() === 'ofType') + .forEach(exp => { + if (exp) { + // remove Type Argument and update actionType + if ( + exp.getTypeArguments().length > 0 && + !exp + .getArguments() + .map(arg => arg.getText()) + .includes('UPDATE') + ) { + exp.removeTypeArgument(exp.getFirstChildByKind(SyntaxKind.TypeReference)); + } + const args = exp.getArguments(); + args.forEach(argument => { + if (!(argument.getText() === 'ROOT_EFFECTS_INIT' || argument.getText() === 'UPDATE')) { + const t = argument.getLastChildByKind(SyntaxKind.Identifier) || argument; + exp.addArgument(`${t.getText().replace(/^\w/, c => c.toLowerCase())}`); + exp.removeArgument(argument); + } + }); + } + }); + return pipe; + } +} diff --git a/schematics/migration/0.20-to-0.21/migrate-action-creators.reducers.ts b/schematics/migration/0.20-to-0.21/migrate-action-creators.reducers.ts new file mode 100644 index 0000000000..af9412cd6d --- /dev/null +++ b/schematics/migration/0.20-to-0.21/migrate-action-creators.reducers.ts @@ -0,0 +1,190 @@ +import { tsquery } from '@phenomnomnominal/tsquery'; +import { CaseClause, SourceFile, SyntaxKind, VariableDeclarationKind } from 'ts-morph'; + +import { ActionCreatorsActionsMorpher } from './migrate-action-creators.actions'; +import { + checkReducerErrorHandler, + checkReducerLoadingOnly, + getReducerFunction, + getStateName, + standardizeIdentifier, +} from './morph-helpers'; + +// tslint:disable: no-console + +export class ActionCreatorsReducerMorpher { + switchStatements: { + identifier: string; + dependsOnAction: boolean; + dependsOnState: boolean; + statements: string; + previousIdentifiers: string[]; + isLoadingOnly: boolean; + isErrorHandler: boolean; + }[]; + dependencies: string[] = []; + constructor(public reducerFile: SourceFile, private actionsMorph: ActionCreatorsActionsMorpher) {} + + migrateReducer() { + if (!this.reducerFile) { + return; + } + console.log('migrating', this.reducerFile.getFilePath()); + if (!this.checkUmigratedFile()) { + return; + } + if (!getReducerFunction(this.reducerFile)) { + return; + } + console.log('replacing reducers...'); + this.addImports(); + this.declareNewReducer(); + this.updateFeatureReducer(); + this.removeOldReducer(); + this.reducerFile.fixMissingImports(); + if (this.dependencies.length > 0) { + console.log(` store depends on foreign actions: `); + this.dependencies.forEach(dep => console.log(` ${dep}`)); + console.log(' please migrate the corresponding stores'); + } + } + /** + * add required imports to prevent problems with automatic adding + */ + private addImports() { + this.reducerFile.addImportDeclaration({ + moduleSpecifier: '@ngrx/store', + namedImports: ['on'], + }); + this.reducerFile.addImportDeclaration({ + moduleSpecifier: 'ish-core/utils/ngrx-creators', + namedImports: ['setLoadingOn', 'setErrorOn'], + }); + } + + /** + * declare new reducer function created with new createReducer factory + */ + private declareNewReducer() { + this.extractReducerContents(); + + // create new reducer function + const reducer = this.reducerFile.addVariableStatement({ + isExported: true, + isDefaultExport: false, + hasDeclareKeyword: false, + declarationKind: VariableDeclarationKind.Const, + declarations: [ + { + name: getReducerFunction(this.reducerFile).getName(), + initializer: 'createReducer()', + type: undefined, + hasExclamationToken: false, + }, + ], + }); + + const stateName = getStateName(this.reducerFile); + + // add first reducer argument + const createReducerFunction = reducer.getFirstDescendantByKindOrThrow(SyntaxKind.CallExpression); + createReducerFunction.addArgument('initialState'); + + // for each switch case, add a new on()-function + this.switchStatements.forEach(clause => { + // name of the actionCreator function + const actionTypesString = [...clause.previousIdentifiers, clause.identifier]; + let arrowFunction; + if (clause.dependsOnAction && clause.dependsOnState) { + arrowFunction = `(state: ${stateName}, action) => ${clause.statements}`; + } else if (!clause.dependsOnAction && clause.dependsOnState) { + arrowFunction = `(state: ${stateName}) => ${clause.statements}`; + } else if (clause.dependsOnAction && !clause.dependsOnState) { + arrowFunction = `(_, action) => ${clause.statements}`; + } else { + arrowFunction = `() => ${clause.statements}`; + } + if (clause.isLoadingOnly) { + createReducerFunction.addArgument(`setLoadingOn(${actionTypesString})`); + } else if (clause.isErrorHandler) { + createReducerFunction.addArgument(`setErrorOn(${actionTypesString})`); + } else { + createReducerFunction.addArgument(`on(${actionTypesString}, ${arrowFunction})`); + } + }); + } + + /** + * update reducer function to use the newly constructed version using createReducer + */ + private updateFeatureReducer() { + getReducerFunction(this.reducerFile).getParameter('action').remove(); + getReducerFunction(this.reducerFile).addParameter({ name: 'action', type: 'Action' }); + getReducerFunction(this.reducerFile) + .getFirstChildByKindOrThrow(SyntaxKind.Block) + .getStatements() + .forEach(statement => statement.remove()); + getReducerFunction(this.reducerFile).setBodyText('return reducer(state,action)'); + } + + /** + * extract information from the old reducer switch statement + */ + private extractReducerContents() { + // retrieve reducer logic from old reducer + this.switchStatements = []; + let previousIdentifiers: string[] = []; + + if (getReducerFunction(this.reducerFile).getDescendantsOfKind(SyntaxKind.SwitchStatement).length === 0) { + throw new Error('this reducer does not include a switch statement. Please migrate manually'); + } + // iterate over reducer switch cases and store info + getReducerFunction(this.reducerFile) + .getFirstDescendantByKind(SyntaxKind.CaseBlock) + .getClauses() + .filter(clause => clause.getKind() === SyntaxKind.CaseClause) + .forEach((clause: CaseClause) => { + if ( + this.actionsMorph.actionTypes && + !this.actionsMorph.actionTypes[clause.getExpression().getText().split('.')[1]] + ) { + this.dependencies.push(clause.getExpression().getText()); + } + // store empty clauses for later use and continue + if (clause.getStatements().length === 0) { + previousIdentifiers.push(standardizeIdentifier(clause.getExpression().getText())); + return; + } + + const clauseBody = clause.getStatements()[0]; + const isLoadingOnly = checkReducerLoadingOnly(clauseBody); + const isErrorHandler = checkReducerErrorHandler(clauseBody); + + // push information about switch statement to array + this.switchStatements.push({ + identifier: standardizeIdentifier(clause.getExpression().getText()), + dependsOnAction: !!tsquery(clauseBody.compilerNode, 'Identifier[name=action]').length, + dependsOnState: !!tsquery(clauseBody.compilerNode, 'Identifier[name=state]').length, + statements: clauseBody.getKind() === SyntaxKind.Block ? clauseBody.getText() : `{${clauseBody.getText()}}`, + previousIdentifiers: [...previousIdentifiers], + isLoadingOnly, + isErrorHandler, + }); + previousIdentifiers = []; + }); + } + + private checkUmigratedFile(): boolean { + if (this.reducerFile.getText().includes('createReducer')) { + console.log('this file is already migrated, skipping...'); + return false; + } else { + return true; + } + } + + private removeOldReducer() { + const oldFunc = getReducerFunction(this.reducerFile); + oldFunc.remove(); + } +} diff --git a/schematics/migration/0.20-to-0.21/morph-helpers.ts b/schematics/migration/0.20-to-0.21/morph-helpers.ts new file mode 100644 index 0000000000..d2a2c98563 --- /dev/null +++ b/schematics/migration/0.20-to-0.21/morph-helpers.ts @@ -0,0 +1,157 @@ +import { tsquery } from '@phenomnomnominal/tsquery'; +import { ConditionalExpression, Expression, Node, Project, SourceFile, Statement, SyntaxKind } from 'ts-morph'; +import * as ts from 'typescript'; + +/** + * returns the new action name from an ActionClass or Action multiple export + * @param identifier string that contains the action name + */ +export function standardizeIdentifier(identifier: string) { + return identifier.includes('.') + ? identifier.split('.')[1].replace(/^\w/, c => c.toLowerCase()) + : identifier.replace(/^\w/, c => c.toLowerCase()); +} + +/** + * helper: checks whether the given expression text belongs to a map operator + * @param identifier expression text + */ +export function isMap(identifier: string) { + return identifier === 'map' || 'concatMap' || 'mergeMap' || 'switchMap' || 'mapTo'; +} + +/** + * returns expression from conditional as array + * @param conditional the conditional to extract expressions from + */ +export function getConditionalWhenExpressions(conditional: ConditionalExpression): Expression[] { + return [conditional.getWhenTrue(), conditional.getWhenFalse()]; +} + +export function getReducerFunction(reducerFile: SourceFile) { + return reducerFile.getFunctions().filter(func => func.getName().endsWith('Reducer'))[0]; +} + +export function getStateName(reducerFile: SourceFile): string { + return reducerFile + .getInterfaces() + .find(i => i.getName().endsWith('State')) + .getName(); +} + +export function checkReducerLoadingOnly(clauseBody: Statement): boolean { + const ret = tsquery( + clauseBody.compilerNode, + 'ReturnStatement > ObjectLiteralExpression' + ) as ts.ObjectLiteralExpression[]; + return ( + ret && + ret.length === 1 && + ret[0].properties.length === 2 && + tsquery(ret[0], 'SpreadAssignment[expression.text=state]').length === 1 && + tsquery(ret[0], 'PropertyAssignment[name.text=loading][initializer.text=true]').length === 1 + ); +} + +export function checkReducerErrorHandler(clauseBody: Statement): boolean { + const ret = tsquery( + clauseBody.compilerNode, + 'ReturnStatement > ObjectLiteralExpression' + ) as ts.ObjectLiteralExpression[]; + return ( + ret && + ret.length === 1 && + ret[0].properties.length === 3 && + tsquery(ret[0], 'SpreadAssignment[expression.text=state]').length === 1 && + tsquery(ret[0], 'PropertyAssignment[name.text=loading][initializer.text=false]').length === 1 && + (tsquery(ret[0], 'PropertyAssignment[name.text=error][initializer.text!=undefined]').length === 1 || + tsquery(ret[0], 'ShorthandPropertyAssignment[name.text=error]').length === 1) + ); +} + +export function rewriteMapErrorToAction(project: Project) { + const sourceFile = project.getSourceFileOrThrow('src/app/core/utils/operators.ts'); + + const func = sourceFile.getFunctionOrThrow('mapErrorToAction'); + + // change parameter + func.getParameters()[0].setType('(props: { error: HttpError }) => T'); + + func + .getBody() + .getDescendantsOfKind(SyntaxKind.NewExpression) + .filter(node => node.getText().startsWith('new actionType')) + .forEach(node => { + node.replaceWithText('actionType({ error: HttpErrorMapper.fromError(err), ...extras })'); + }); + + const testSourceFile = project.getSourceFileOrThrow('src/app/core/utils/operators.spec.ts'); + + if ( + !testSourceFile.getImportDeclaration(imp => + imp.getModuleSpecifier().getText().includes('ish-core/utils/ngrx-creators') + ) + ) { + testSourceFile.addImportDeclaration({ + moduleSpecifier: 'ish-core/utils/ngrx-creators', + namedImports: ['httpError'], + }); + } + + testSourceFile.forEachDescendant(node => { + if (Node.isClassDeclaration(node) && node.getName() === 'DummyFail') { + node.replaceWithText("const dummyFail = createAction('dummy', httpError());"); + } + }); + testSourceFile.forEachDescendant(node => { + if (Node.isIdentifier(node) && node.getText() === 'DummyFail') { + node.replaceWithText('dummyFail'); + } + }); +} + +export function createPayloadAdapter(project: Project) { + project.createSourceFile( + 'src/app/core/utils/ngrx-creators.ts', + ` +import { ActionCreator, On, on } from '@ngrx/store'; +import { HttpError } from 'ish-core/models/http-error/http-error.model'; + +export function payload

() { + return (args: P) => ({ payload: { ...args } }); +} + +export function httpError

() { + return (args: { error: HttpError } & P) => ({ payload: { ...args } }); +} + +export function setLoadingOn(...actionCreators: ActionCreator[]): On { + const stateFnc = (state: S) => ({ + ...state, + loading: true, + }); + if (actionCreators.length === 1) { + return on(actionCreators[0], stateFnc); + } else { + return on(actionCreators[0], ...actionCreators.splice(1), stateFnc); + } +} + +export function setErrorOn( + ...actionCreators: ActionCreator[] +): On { + const stateFnc = (state: S, action: { payload: { error: HttpError }; type: string }) => ({ + ...state, + error: action.payload.error, + loading: false, + }); + if (actionCreators.length === 1) { + return on(actionCreators[0], stateFnc); + } else { + return on(actionCreators[0], ...actionCreators.splice(1), stateFnc); + } +} +`, + { overwrite: true } + ); +} diff --git a/schematics/migration/0.20-to-0.21/store-migration.ts b/schematics/migration/0.20-to-0.21/store-migration.ts new file mode 100644 index 0000000000..2b7cc0c84b --- /dev/null +++ b/schematics/migration/0.20-to-0.21/store-migration.ts @@ -0,0 +1,56 @@ +import { Project } from 'ts-morph'; + +import { ActionCreatorsActionsMorpher } from './migrate-action-creators.actions'; +import { ActionCreatorsEffectMorpher } from './migrate-action-creators.effects'; +import { ActionCreatorsReducerMorpher } from './migrate-action-creators.reducers'; +import { createPayloadAdapter, rewriteMapErrorToAction } from './morph-helpers'; + +// tslint:disable: no-console + +const project = new Project({ tsConfigFilePath: 'tsconfig.all.json' }); + +rewriteMapErrorToAction(project); + +createPayloadAdapter(project); + +const morpherPaths = + process.argv.length > 2 + ? process.argv.splice(2).map(p => project.getDirectoryOrThrow(p)) + : project + .getDirectories() + .filter(d => !d.getDirectories().length) + .filter(d => d.getPath().includes('/store/')); + +const morphers = morpherPaths.map(dir => { + const storeBaseNames = `${dir.getPath()}/${dir.getBaseName()}`; + const actionsMorph = new ActionCreatorsActionsMorpher(project.getSourceFile(storeBaseNames + '.actions.ts')); + return { + storeName: dir.getPath(), + actionsMorph, + reducerMorph: new ActionCreatorsReducerMorpher(project.getSourceFile(storeBaseNames + '.reducer.ts'), actionsMorph), + effectsMorphs: dir.getSourceFiles('*.effects.ts').map(eff => new ActionCreatorsEffectMorpher(eff)), + }; +}); + +console.log('updating all actions'); + +morphers.forEach(morpher => { + morpher.actionsMorph.migrateActions(); + project.saveSync(); +}); + +console.log('updating all reducers'); + +morphers.forEach(morpher => { + morpher.reducerMorph.migrateReducer(); + project.saveSync(); +}); + +console.log('updating all effects'); + +morphers.forEach(morpher => { + morpher.effectsMorphs.forEach(m => { + m.migrateEffects(); + project.saveSync(); + }); +}); diff --git a/tslint-rules/src/checkActionsForCreatorMigrationRule.ts b/tslint-rules/src/checkActionsForCreatorMigrationRule.ts new file mode 100644 index 0000000000..200c1ee58c --- /dev/null +++ b/tslint-rules/src/checkActionsForCreatorMigrationRule.ts @@ -0,0 +1,33 @@ +import { tsquery } from '@phenomnomnominal/tsquery'; +import * as Lint from 'tslint'; +import * as ts from 'typescript'; + +/** + * TODO: this rule becomes obsolete after 0.21 release + */ +export class Rule extends Lint.Rules.AbstractRule { + apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { + if (sourceFile.fileName.endsWith('.actions.ts')) { + return this.applyWithFunction(sourceFile, ctx => { + ctx.sourceFile.statements.filter(ts.isClassDeclaration).forEach(actionClass => { + const enumName = (tsquery( + actionClass, + 'PropertyDeclaration[name.text=type] > PropertyAccessExpression' + )[0] as ts.PropertyAccessExpression).name; + if (enumName.text !== actionClass.name.text) { + ctx.addFailureAtNode(enumName, 'Enum name does not equal action name.'); + } + + tsquery(actionClass, 'Constructor > Parameter[name.text=payload]').forEach( + (payload: ts.ParameterDeclaration) => { + if (payload.initializer || payload.questionToken) { + ctx.addFailureAtNode(payload, 'Optional payloads are not supported.'); + } + } + ); + }); + }); + } + return []; + } +} diff --git a/tslint-rules/src/ngrxUseComplexTypeWithActionPayloadRule.ts b/tslint-rules/src/ngrxUseComplexTypeWithActionPayloadRule.ts deleted file mode 100644 index 17450ef5d4..0000000000 --- a/tslint-rules/src/ngrxUseComplexTypeWithActionPayloadRule.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { tsquery } from '@phenomnomnominal/tsquery'; -import * as Lint from 'tslint'; -import * as ts from 'typescript'; - -export class Rule extends Lint.Rules.AbstractRule { - apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { - if (!sourceFile.fileName.endsWith('actions.ts')) { - return []; - } - - return this.applyWithFunction(sourceFile, ctx => { - tsquery(ctx.sourceFile, 'CallExpression').forEach((node: ts.CallExpression) => { - if (node.getChildAt(0).getText() === 'action') { - if (node.getChildAt(2).getChildCount() > 3) { - ctx.addFailureAtNode(node, 'Actions should have only one payload parameter.'); - } - const payload = node.getChildAt(2).getChildAt(2); - if (payload) { - if (payload.getChildAt(0).getText() !== 'payload') { - ctx.addFailureAtNode(node, 'Actions should have only one payload parameter.'); - } else if (!payload.getChildAt(2).getText().startsWith('{')) { - ctx.addFailureAtNode(payload, 'The payload of actions should be a complex type with named content.'); - } - } - } - }); - - tsquery(ctx.sourceFile, '*') - .filter(ts.isConstructorDeclaration) - .forEach((node: ts.ConstructorDeclaration) => { - const isActionClass = node.parent - .getChildren() - .map(c => c.getText() === 'implements Action') - .reduce((a, b) => a || b); - if (isActionClass) { - const constructorParameterList = node.getChildAt(2); - if (constructorParameterList.getChildCount() > 1) { - ctx.addFailureAtNode( - constructorParameterList, - 'Actions should have only one payload parameter called "payload".' - ); - } else { - const firstConstructorParameter = constructorParameterList.getChildAt(0); - if (firstConstructorParameter.getChildAt(1).getText() !== 'payload') { - ctx.addFailureAtNode( - firstConstructorParameter.getChildAt(1), - 'Actions should have only one payload parameter called "payload".' - ); - } else { - const typeOfFirstConstructorParameter = firstConstructorParameter.getChildAt( - firstConstructorParameter.getChildCount() - 1 - ); - if ( - !typeOfFirstConstructorParameter.getText().startsWith('{') && - !typeOfFirstConstructorParameter.getText().endsWith('Type') - ) { - ctx.addFailureAtNode( - typeOfFirstConstructorParameter, - 'The payload of actions should be a complex type with named content.' - ); - } - } - } - } - }); - }); - } -} diff --git a/tslint-rules/src/ngrxUseOftypeWithTypeRule.ts b/tslint-rules/src/ngrxUseOftypeWithTypeRule.ts deleted file mode 100644 index 8710d15dec..0000000000 --- a/tslint-rules/src/ngrxUseOftypeWithTypeRule.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { tsquery } from '@phenomnomnominal/tsquery'; -import * as Lint from 'tslint'; -import { getNextToken } from 'tsutils'; -import * as ts from 'typescript'; - -export class Rule extends Lint.Rules.AbstractRule { - apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { - return this.applyWithFunction(sourceFile, ctx => { - tsquery(ctx.sourceFile, 'Identifier[name="ofType"]').forEach(node => { - const ofTypeOperatorStatement = node.parent; - if ( - !!ofTypeOperatorStatement && - ofTypeOperatorStatement.kind === ts.SyntaxKind.CallExpression && - ofTypeOperatorStatement.getChildCount() > 2 && - ofTypeOperatorStatement.getChildAt(1).getText() !== '<' - ) { - const followedOperator = getNextToken(getNextToken(ofTypeOperatorStatement)); - - if (/^(m|switchM|flatM|concatM|exhaustM|mergeM)ap$/.test(followedOperator.getText())) { - const followedOperatorBody = followedOperator.parent.getChildAt(2).getChildAt(0); - if (!followedOperatorBody.getText().startsWith('()')) { - ctx.addFailureAtNode( - ofTypeOperatorStatement.getChildAt(0), - 'use ofType operator with specific action type' - ); - } - } - } - }); - }); - } -} diff --git a/tslint.json b/tslint.json index eb26ee791d..9e9525778f 100644 --- a/tslint.json +++ b/tslint.json @@ -162,8 +162,6 @@ "rxjs-throw-error": { "severity": "off" }, "private-destroy-field": true, "ngrx-use-empty-store-type": true, - "ngrx-use-oftype-with-type": true, - "ngrx-use-complex-type-with-action-payload": true, "semicolon": [true, "always"], "triple-equals": true, "typedef-whitespace": [ @@ -412,6 +410,30 @@ "filePattern": "^.*\\.spec\\.ts*$", "message": "Use the testing helpers '*StoreModule.forTesting' in tests instead." }, + { + "import": "^Effect$", + "from": "@ngrx/effects", + "filePattern": "^.*(\\.spec|\\.effects)\\.ts*$", + "message": "The old way of declaring effects is deprecated, use 'createEffect'." + }, + { + "import": "^Action$", + "from": "@ngrx/store", + "filePattern": "^(?!.*\\.spec\\.ts$).*\\.actions\\.ts*$", + "message": "The old way of declaring actions is deprecated, use 'createAction'." + }, + { + "import": "^props$", + "from": "@ngrx/store", + "filePattern": "^.*\\.actions\\.ts*$", + "message": "Do not use 'props' directly with 'createAction', use our helper functions 'payload' and 'httpError' from 'ish-core/utils/action-creators' instead." + }, + { + "import": "^HttpError$", + "from": ".*http-error.model", + "filePattern": "^.*\\.actions\\.ts*$", + "message": "Do not use 'HttpError' explicitly, please use 'httpError' from 'ish-core/utils/action-creators' instead." + }, { "import": "^IconModule$", "from": "ish-core/icon.module",