diff --git a/.prettierignore b/.prettierignore index afeeff0261..0b5d389993 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,6 @@ /dist /modules/schematics/src/*/files/* +/modules/**/schematics/**/files/* /tmp package-lock.json package.json diff --git a/modules/effects/BUILD b/modules/effects/BUILD index f604dd153d..c1117583b2 100644 --- a/modules/effects/BUILD +++ b/modules/effects/BUILD @@ -25,6 +25,7 @@ ng_package( entry_point = "modules/effects/index.js", packages = [ "//modules/effects/migrations:npm_package", + "//modules/effects/schematics:npm_package", "//modules/effects/schematics-core:npm_package", ], deps = [ diff --git a/modules/effects/package.json b/modules/effects/package.json index 1d57c2fe70..3d9ea03035 100644 --- a/modules/effects/package.json +++ b/modules/effects/package.json @@ -6,6 +6,14 @@ "type": "git", "url": "https://github.com/ngrx/platform.git" }, + "keywords": [ + "RxJS", + "Angular", + "Redux", + "NgRx", + "Schematics", + "Angular CLI" + ], "author": "NgRx", "license": "MIT", "bugs": { @@ -17,6 +25,7 @@ "@ngrx/store": "0.0.0-PLACEHOLDER", "rxjs": "RXJS_VERSION" }, + "schematics": "MODULE_SCHEMATICS_COLLECTION", "ng-update": { "packageGroup": "NG_UPDATE_PACKAGE_GROUP", "migrations": "NG_UPDATE_MIGRATIONS" diff --git a/modules/effects/schematics-core/index.ts b/modules/effects/schematics-core/index.ts index b148524ec4..f7b020f0da 100644 --- a/modules/effects/schematics-core/index.ts +++ b/modules/effects/schematics-core/index.ts @@ -70,4 +70,9 @@ export const stringUtils = { }; export { updatePackage } from './utility/update'; + export { parseName } from './utility/parse-name'; + +export { addPackageToPackageJson } from './utility/package'; + +export { platformVersion } from './utility/libs-version'; diff --git a/modules/effects/schematics-core/utility/libs-version.ts b/modules/effects/schematics-core/utility/libs-version.ts new file mode 100644 index 0000000000..a52bafee3e --- /dev/null +++ b/modules/effects/schematics-core/utility/libs-version.ts @@ -0,0 +1 @@ +export const platformVersion = '^6.0.0'; diff --git a/modules/effects/schematics-core/utility/package.ts b/modules/effects/schematics-core/utility/package.ts new file mode 100644 index 0000000000..ecbc74b463 --- /dev/null +++ b/modules/effects/schematics-core/utility/package.ts @@ -0,0 +1,27 @@ +import { Tree } from '@angular-devkit/schematics'; + +/** + * Adds a package to the package.json + */ +export function addPackageToPackageJson( + host: Tree, + type: string, + pkg: string, + version: string +): Tree { + if (host.exists('package.json')) { + const sourceText = host.read('package.json')!.toString('utf-8'); + const json = JSON.parse(sourceText); + if (!json[type]) { + json[type] = {}; + } + + if (!json[type][pkg]) { + json[type][pkg] = version; + } + + host.overwrite('package.json', JSON.stringify(json, null, 2)); + } + + return host; +} diff --git a/modules/effects/schematics/BUILD b/modules/effects/schematics/BUILD new file mode 100644 index 0000000000..8677754c94 --- /dev/null +++ b/modules/effects/schematics/BUILD @@ -0,0 +1,33 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ts_library", "npm_package") + +ts_library( + name = "schematics", + srcs = glob( + [ + "**/*.ts", + ], + exclude = [ + "**/*.spec.ts", + "**/files/**/*", + ], + ), + module_name = "@ngrx/effects/schematics", + deps = [ + "//modules/effects/schematics-core", + ], +) + +npm_package( + name = "npm_package", + srcs = [ + ":collection.json", + ] + glob([ + "**/files/**/*", + "**/schema.json", + ]), + deps = [ + ":schematics", + ], +) diff --git a/modules/effects/schematics/collection.json b/modules/effects/schematics/collection.json new file mode 100644 index 0000000000..537b84cc1a --- /dev/null +++ b/modules/effects/schematics/collection.json @@ -0,0 +1,10 @@ +{ + "schematics": { + "ng-add": { + "aliases": ["init"], + "factory": "./ng-add", + "schema": "./ng-add/schema.json", + "description": "Add root side effect class" + } + } +} diff --git a/modules/effects/schematics/ng-add/files/__name@dasherize@if-flat__/__name@dasherize__.effects.ts b/modules/effects/schematics/ng-add/files/__name@dasherize@if-flat__/__name@dasherize__.effects.ts new file mode 100644 index 0000000000..d20b9f40a4 --- /dev/null +++ b/modules/effects/schematics/ng-add/files/__name@dasherize@if-flat__/__name@dasherize__.effects.ts @@ -0,0 +1,7 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect } from '@ngrx/effects'; + +@Injectable() +export class <%= classify(name) %>Effects { + constructor(private actions$: Actions) {} +} diff --git a/modules/effects/schematics/ng-add/files/__name@dasherize@if-flat__/__name@dasherize__.effects__dot__spec.ts b/modules/effects/schematics/ng-add/files/__name@dasherize@if-flat__/__name@dasherize__.effects__dot__spec.ts new file mode 100644 index 0000000000..881d8e3276 --- /dev/null +++ b/modules/effects/schematics/ng-add/files/__name@dasherize@if-flat__/__name@dasherize__.effects__dot__spec.ts @@ -0,0 +1,25 @@ +import { TestBed, inject } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Observable } from 'rxjs'; + +import { <%= classify(name) %>Effects } from './<%= dasherize(name) %>.effects'; + +describe('<%= classify(name) %>Effects', () => { + let actions$: Observable; + let effects: <%= classify(name) %>Effects; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + <%= classify(name) %>Effects, + provideMockActions(() => actions$) + ] + }); + + effects = TestBed.get(<%= classify(name) %>Effects); + }); + + it('should be created', () => { + expect(effects).toBeTruthy(); + }); +}); diff --git a/modules/effects/schematics/ng-add/index.spec.ts b/modules/effects/schematics/ng-add/index.spec.ts new file mode 100644 index 0000000000..53ebc7d659 --- /dev/null +++ b/modules/effects/schematics/ng-add/index.spec.ts @@ -0,0 +1,185 @@ +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import { getFileContent } from '@schematics/angular/utility/test'; +import * as path from 'path'; +import { Schema as RootEffectOptions } from './schema'; +import { + getTestProjectPath, + createWorkspace, + createAppModuleWithEffects, +} from '../../../schematics-core/testing'; + +describe('Effect ng-add Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@ngrx/effects', + path.join(__dirname, '../collection.json') + ); + + const defaultOptions: RootEffectOptions = { + name: 'foo', + skipPackageJson: false, + project: 'bar', + spec: true, + module: undefined, + flat: false, + group: false, + }; + + const projectPath = getTestProjectPath(); + + let appTree: UnitTestTree; + + beforeEach(() => { + appTree = createWorkspace(schematicRunner, appTree); + }); + + it('should update package.json', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const packageJson = JSON.parse(getFileContent(tree, '/package.json')); + + expect(packageJson.dependencies['@ngrx/effects']).toBeDefined(); + }); + + it('should skip package.json update', () => { + const options = { ...defaultOptions, skipPackageJson: true }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const packageJson = JSON.parse(getFileContent(tree, '/package.json')); + + expect(packageJson.dependencies['@ngrx/effects']).toBeUndefined(); + }); + + it('should create an effect', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const files = tree.files; + expect( + files.indexOf(`${projectPath}/src/app/foo/foo.effects.spec.ts`) + ).toBeGreaterThanOrEqual(0); + expect( + files.indexOf(`${projectPath}/src/app/foo/foo.effects.ts`) + ).toBeGreaterThanOrEqual(0); + }); + + it('should not be provided by default', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent(`${projectPath}/src/app/app.module.ts`); + expect(content).not.toMatch( + /import { FooEffects } from '.\/foo\/foo.effects'/ + ); + }); + + it('should import into a specified module', () => { + const options = { ...defaultOptions, module: 'app.module.ts' }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent(`${projectPath}/src/app/app.module.ts`); + expect(content).toMatch(/import { FooEffects } from '.\/foo\/foo.effects'/); + }); + + it('should fail if specified module does not exist', () => { + const options = { + ...defaultOptions, + module: `${projectPath}/src/app/app.moduleXXX.ts`, + }; + let thrownError: Error | null = null; + try { + schematicRunner.runSchematic('effects', options, appTree); + } catch (err) { + thrownError = err; + } + expect(thrownError).toBeDefined(); + }); + + it('should respect the spec flag', () => { + const options = { ...defaultOptions, spec: false }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const files = tree.files; + expect( + files.indexOf(`${projectPath}/src/app/foo/foo.effects.ts`) + ).toBeGreaterThanOrEqual(0); + expect( + files.indexOf(`${projectPath}/src/app/foo/foo.effects.spec.ts`) + ).toEqual(-1); + }); + + it('should register the root effect in the provided module', () => { + const options = { ...defaultOptions, module: 'app.module.ts' }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent(`${projectPath}/src/app/app.module.ts`); + + expect(content).toMatch(/EffectsModule\.forRoot\(\[FooEffects\]\)/); + }); + + it('should add an effect to the empty array of registered effects', () => { + const storeModule = `${projectPath}/src/app/store.module.ts`; + const options = { + ...defaultOptions, + module: 'store.module.ts', + }; + appTree = createAppModuleWithEffects( + appTree, + storeModule, + 'EffectsModule.forRoot([])' + ); + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent(storeModule); + + expect(content).toMatch(/EffectsModule\.forRoot\(\[FooEffects\]\)/); + }); + + it('should add an effect to the existing registered root effects', () => { + const storeModule = `${projectPath}/src/app/store.module.ts`; + const options = { + ...defaultOptions, + module: 'store.module.ts', + }; + appTree = createAppModuleWithEffects( + appTree, + storeModule, + 'EffectsModule.forRoot([UserEffects])' + ); + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent(storeModule); + + expect(content).toMatch( + /EffectsModule\.forRoot\(\[UserEffects, FooEffects\]\)/ + ); + }); + + it('should not add an effect to registered effects defined with a variable', () => { + const storeModule = `${projectPath}/src/app/store.module.ts`; + const options = { ...defaultOptions, module: 'store.module.ts' }; + appTree = createAppModuleWithEffects( + appTree, + storeModule, + 'EffectsModule.forRoot(effects)' + ); + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent(storeModule); + + expect(content).not.toMatch(/EffectsModule\.forRoot\(\[FooEffects\]\)/); + }); + + it('should group within an "effects" folder if group is set', () => { + const options = { ...defaultOptions, flat: true, spec: false, group: true }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const files = tree.files; + expect( + files.indexOf(`${projectPath}/src/app/effects/foo.effects.ts`) + ).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/modules/effects/schematics/ng-add/index.ts b/modules/effects/schematics/ng-add/index.ts new file mode 100644 index 0000000000..6766636fb4 --- /dev/null +++ b/modules/effects/schematics/ng-add/index.ts @@ -0,0 +1,155 @@ +import { + Rule, + SchematicContext, + SchematicsException, + Tree, + apply, + branchAndMerge, + chain, + filter, + mergeWith, + move, + noop, + template, + url, +} from '@angular-devkit/schematics'; +import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; +import * as ts from 'typescript'; +import { + stringUtils, + insertImport, + buildRelativePath, + addImportToModule, + InsertChange, + getProjectPath, + findModuleFromOptions, + addPackageToPackageJson, + platformVersion, + parseName, +} from '@ngrx/effects/schematics-core'; +import { Schema as RootEffectOptions } from './schema'; + +function addImportToNgModule(options: RootEffectOptions): Rule { + return (host: Tree) => { + const modulePath = options.module; + + if (!modulePath) { + return host; + } + + if (!host.exists(modulePath)) { + throw new Error('Specified module does not exist'); + } + + const text = host.read(modulePath); + if (text === null) { + throw new SchematicsException(`File ${modulePath} does not exist.`); + } + const sourceText = text.toString('utf-8'); + + const source = ts.createSourceFile( + modulePath, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + const effectsName = `${stringUtils.classify(`${options.name}Effects`)}`; + + const effectsModuleImport = insertImport( + source, + modulePath, + 'EffectsModule', + '@ngrx/effects' + ); + + const effectsPath = + `/${options.path}/` + + (options.flat ? '' : stringUtils.dasherize(options.name) + '/') + + (options.group ? 'effects/' : '') + + stringUtils.dasherize(options.name) + + '.effects'; + const relativePath = buildRelativePath(modulePath, effectsPath); + const effectsImport = insertImport( + source, + modulePath, + effectsName, + relativePath + ); + const [effectsNgModuleImport] = addImportToModule( + source, + modulePath, + `EffectsModule.forRoot([${effectsName}])`, + relativePath + ); + const changes = [effectsModuleImport, effectsImport, effectsNgModuleImport]; + const recorder = host.beginUpdate(modulePath); + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(recorder); + + return host; + }; +} + +function addNgRxEffectsToPackageJson() { + return (host: Tree, context: SchematicContext) => { + addPackageToPackageJson( + host, + 'dependencies', + '@ngrx/effects', + platformVersion + ); + context.addTask(new NodePackageInstallTask()); + return host; + }; +} + +export default function(options: RootEffectOptions): Rule { + return (host: Tree, context: SchematicContext) => { + options.path = getProjectPath(host, options); + + if (options.module) { + options.module = findModuleFromOptions(host, options); + } + + const parsedPath = parseName(options.path, options.name); + options.name = parsedPath.name; + options.path = parsedPath.path; + + const templateSource = apply(url('./files'), [ + options.spec ? noop() : filter(path => !path.endsWith('__spec.ts')), + template({ + ...stringUtils, + 'if-flat': (s: string) => + stringUtils.group( + options.flat ? '' : s, + options.group ? 'effects' : '' + ), + ...(options as object), + dot: () => '.', + } as any), + move(parsedPath.path), + ]); + + return chain([ + options && options.skipPackageJson + ? noop() + : addNgRxEffectsToPackageJson(), + branchAndMerge( + chain([ + filter( + path => + path.endsWith('.module.ts') && + !path.endsWith('-routing.module.ts') + ), + addImportToNgModule(options), + mergeWith(templateSource), + ]) + ), + ])(host, context); + }; +} diff --git a/modules/effects/schematics/ng-add/schema.json b/modules/effects/schematics/ng-add/schema.json new file mode 100644 index 0000000000..d22ac93d1d --- /dev/null +++ b/modules/effects/schematics/ng-add/schema.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsNgRxRootEffect", + "title": "NgRx Root Effect Options Schema", + "type": "object", + "properties": { + "name": { + "description": "The name of the effect.", + "type": "string", + "$default": { + "$source": "argv", + "index": 0 + } + }, + "skipPackageJson": { + "type": "boolean", + "default": false, + "description": + "Do not add @ngrx/effects as dependency to package.json (e.g., --skipPackageJson)." + }, + "path": { + "type": "string", + "format": "path", + "description": "The path to create the component.", + "visible": false + }, + "flat": { + "type": "boolean", + "default": true, + "description": "Flag to indicate if a dir is created." + }, + "spec": { + "type": "boolean", + "default": true, + "description": "Specifies if a spec file is generated." + }, + "module": { + "type": "string", + "default": "", + "description": "Allows specification of the declaring module.", + "alias": "m", + "subtype": "filepath" + }, + "group": { + "type": "boolean", + "default": false, + "description": "Group effects file within 'effects' folder", + "aliases": ["g"] + } + }, + "required": [] +} diff --git a/modules/effects/schematics/ng-add/schema.ts b/modules/effects/schematics/ng-add/schema.ts new file mode 100644 index 0000000000..59bded8cd1 --- /dev/null +++ b/modules/effects/schematics/ng-add/schema.ts @@ -0,0 +1,10 @@ +export interface Schema { + name: string; + skipPackageJson?: boolean; + path?: string; + project?: string; + flat?: boolean; + spec?: boolean; + module?: string; + group?: boolean; +} diff --git a/modules/entity/schematics-core/index.ts b/modules/entity/schematics-core/index.ts index b148524ec4..f7b020f0da 100644 --- a/modules/entity/schematics-core/index.ts +++ b/modules/entity/schematics-core/index.ts @@ -70,4 +70,9 @@ export const stringUtils = { }; export { updatePackage } from './utility/update'; + export { parseName } from './utility/parse-name'; + +export { addPackageToPackageJson } from './utility/package'; + +export { platformVersion } from './utility/libs-version'; diff --git a/modules/entity/schematics-core/utility/libs-version.ts b/modules/entity/schematics-core/utility/libs-version.ts new file mode 100644 index 0000000000..a52bafee3e --- /dev/null +++ b/modules/entity/schematics-core/utility/libs-version.ts @@ -0,0 +1 @@ +export const platformVersion = '^6.0.0'; diff --git a/modules/entity/schematics-core/utility/package.ts b/modules/entity/schematics-core/utility/package.ts new file mode 100644 index 0000000000..ecbc74b463 --- /dev/null +++ b/modules/entity/schematics-core/utility/package.ts @@ -0,0 +1,27 @@ +import { Tree } from '@angular-devkit/schematics'; + +/** + * Adds a package to the package.json + */ +export function addPackageToPackageJson( + host: Tree, + type: string, + pkg: string, + version: string +): Tree { + if (host.exists('package.json')) { + const sourceText = host.read('package.json')!.toString('utf-8'); + const json = JSON.parse(sourceText); + if (!json[type]) { + json[type] = {}; + } + + if (!json[type][pkg]) { + json[type][pkg] = version; + } + + host.overwrite('package.json', JSON.stringify(json, null, 2)); + } + + return host; +} diff --git a/modules/router-store/schematics-core/index.ts b/modules/router-store/schematics-core/index.ts index b148524ec4..f7b020f0da 100644 --- a/modules/router-store/schematics-core/index.ts +++ b/modules/router-store/schematics-core/index.ts @@ -70,4 +70,9 @@ export const stringUtils = { }; export { updatePackage } from './utility/update'; + export { parseName } from './utility/parse-name'; + +export { addPackageToPackageJson } from './utility/package'; + +export { platformVersion } from './utility/libs-version'; diff --git a/modules/router-store/schematics-core/utility/libs-version.ts b/modules/router-store/schematics-core/utility/libs-version.ts new file mode 100644 index 0000000000..a52bafee3e --- /dev/null +++ b/modules/router-store/schematics-core/utility/libs-version.ts @@ -0,0 +1 @@ +export const platformVersion = '^6.0.0'; diff --git a/modules/router-store/schematics-core/utility/package.ts b/modules/router-store/schematics-core/utility/package.ts new file mode 100644 index 0000000000..ecbc74b463 --- /dev/null +++ b/modules/router-store/schematics-core/utility/package.ts @@ -0,0 +1,27 @@ +import { Tree } from '@angular-devkit/schematics'; + +/** + * Adds a package to the package.json + */ +export function addPackageToPackageJson( + host: Tree, + type: string, + pkg: string, + version: string +): Tree { + if (host.exists('package.json')) { + const sourceText = host.read('package.json')!.toString('utf-8'); + const json = JSON.parse(sourceText); + if (!json[type]) { + json[type] = {}; + } + + if (!json[type][pkg]) { + json[type][pkg] = version; + } + + host.overwrite('package.json', JSON.stringify(json, null, 2)); + } + + return host; +} diff --git a/modules/schematics-core/index.ts b/modules/schematics-core/index.ts index b148524ec4..f7b020f0da 100644 --- a/modules/schematics-core/index.ts +++ b/modules/schematics-core/index.ts @@ -70,4 +70,9 @@ export const stringUtils = { }; export { updatePackage } from './utility/update'; + export { parseName } from './utility/parse-name'; + +export { addPackageToPackageJson } from './utility/package'; + +export { platformVersion } from './utility/libs-version'; diff --git a/modules/schematics-core/utility/libs-version.ts b/modules/schematics-core/utility/libs-version.ts new file mode 100644 index 0000000000..a52bafee3e --- /dev/null +++ b/modules/schematics-core/utility/libs-version.ts @@ -0,0 +1 @@ +export const platformVersion = '^6.0.0'; diff --git a/modules/schematics-core/utility/package.ts b/modules/schematics-core/utility/package.ts new file mode 100644 index 0000000000..ecbc74b463 --- /dev/null +++ b/modules/schematics-core/utility/package.ts @@ -0,0 +1,27 @@ +import { Tree } from '@angular-devkit/schematics'; + +/** + * Adds a package to the package.json + */ +export function addPackageToPackageJson( + host: Tree, + type: string, + pkg: string, + version: string +): Tree { + if (host.exists('package.json')) { + const sourceText = host.read('package.json')!.toString('utf-8'); + const json = JSON.parse(sourceText); + if (!json[type]) { + json[type] = {}; + } + + if (!json[type][pkg]) { + json[type][pkg] = version; + } + + host.overwrite('package.json', JSON.stringify(json, null, 2)); + } + + return host; +} diff --git a/modules/schematics/schematics-core/index.ts b/modules/schematics/schematics-core/index.ts index b148524ec4..f7b020f0da 100644 --- a/modules/schematics/schematics-core/index.ts +++ b/modules/schematics/schematics-core/index.ts @@ -70,4 +70,9 @@ export const stringUtils = { }; export { updatePackage } from './utility/update'; + export { parseName } from './utility/parse-name'; + +export { addPackageToPackageJson } from './utility/package'; + +export { platformVersion } from './utility/libs-version'; diff --git a/modules/schematics/schematics-core/utility/libs-version.ts b/modules/schematics/schematics-core/utility/libs-version.ts new file mode 100644 index 0000000000..a52bafee3e --- /dev/null +++ b/modules/schematics/schematics-core/utility/libs-version.ts @@ -0,0 +1 @@ +export const platformVersion = '^6.0.0'; diff --git a/modules/schematics/schematics-core/utility/package.ts b/modules/schematics/schematics-core/utility/package.ts new file mode 100644 index 0000000000..ecbc74b463 --- /dev/null +++ b/modules/schematics/schematics-core/utility/package.ts @@ -0,0 +1,27 @@ +import { Tree } from '@angular-devkit/schematics'; + +/** + * Adds a package to the package.json + */ +export function addPackageToPackageJson( + host: Tree, + type: string, + pkg: string, + version: string +): Tree { + if (host.exists('package.json')) { + const sourceText = host.read('package.json')!.toString('utf-8'); + const json = JSON.parse(sourceText); + if (!json[type]) { + json[type] = {}; + } + + if (!json[type][pkg]) { + json[type][pkg] = version; + } + + host.overwrite('package.json', JSON.stringify(json, null, 2)); + } + + return host; +} diff --git a/modules/schematics/src/effect/index.ts b/modules/schematics/src/effect/index.ts index ba2e9e86e4..2536f4b65a 100644 --- a/modules/schematics/src/effect/index.ts +++ b/modules/schematics/src/effect/index.ts @@ -13,18 +13,18 @@ import { template, url, } from '@angular-devkit/schematics'; -import * as ts from 'typescript'; import { - stringUtils, - insertImport, - buildRelativePath, - addImportToModule, InsertChange, - getProjectPath, + addImportToModule, + buildRelativePath, findModuleFromOptions, + getProjectPath, + insertImport, + parseName, + stringUtils, } from '@ngrx/schematics/schematics-core'; +import * as ts from 'typescript'; import { Schema as EffectOptions } from './schema'; -import { parseName } from '@ngrx/schematics/schematics-core'; function addImportToNgModule(options: EffectOptions): Rule { return (host: Tree) => { diff --git a/modules/schematics/src/store/files/__statePath__/index.ts b/modules/schematics/src/store/files/__path__/__statePath__/index.ts similarity index 100% rename from modules/schematics/src/store/files/__statePath__/index.ts rename to modules/schematics/src/store/files/__path__/__statePath__/index.ts diff --git a/modules/schematics/src/store/index.ts b/modules/schematics/src/store/index.ts index 9f547f39e7..596aeaa6ac 100644 --- a/modules/schematics/src/store/index.ts +++ b/modules/schematics/src/store/index.ts @@ -8,7 +8,6 @@ import { chain, filter, mergeWith, - move, template, url, } from '@angular-devkit/schematics'; @@ -161,7 +160,6 @@ export default function(options: StoreOptions): Rule { ...(options as object), environmentsPath, } as any), - move(parsedPath.path), ]); return chain([ diff --git a/modules/store-devtools/schematics-core/index.ts b/modules/store-devtools/schematics-core/index.ts index b148524ec4..f7b020f0da 100644 --- a/modules/store-devtools/schematics-core/index.ts +++ b/modules/store-devtools/schematics-core/index.ts @@ -70,4 +70,9 @@ export const stringUtils = { }; export { updatePackage } from './utility/update'; + export { parseName } from './utility/parse-name'; + +export { addPackageToPackageJson } from './utility/package'; + +export { platformVersion } from './utility/libs-version'; diff --git a/modules/store-devtools/schematics-core/utility/libs-version.ts b/modules/store-devtools/schematics-core/utility/libs-version.ts new file mode 100644 index 0000000000..a52bafee3e --- /dev/null +++ b/modules/store-devtools/schematics-core/utility/libs-version.ts @@ -0,0 +1 @@ +export const platformVersion = '^6.0.0'; diff --git a/modules/store-devtools/schematics-core/utility/package.ts b/modules/store-devtools/schematics-core/utility/package.ts new file mode 100644 index 0000000000..ecbc74b463 --- /dev/null +++ b/modules/store-devtools/schematics-core/utility/package.ts @@ -0,0 +1,27 @@ +import { Tree } from '@angular-devkit/schematics'; + +/** + * Adds a package to the package.json + */ +export function addPackageToPackageJson( + host: Tree, + type: string, + pkg: string, + version: string +): Tree { + if (host.exists('package.json')) { + const sourceText = host.read('package.json')!.toString('utf-8'); + const json = JSON.parse(sourceText); + if (!json[type]) { + json[type] = {}; + } + + if (!json[type][pkg]) { + json[type][pkg] = version; + } + + host.overwrite('package.json', JSON.stringify(json, null, 2)); + } + + return host; +} diff --git a/modules/store/BUILD b/modules/store/BUILD index 7590508018..b6e7c05e4d 100644 --- a/modules/store/BUILD +++ b/modules/store/BUILD @@ -20,6 +20,7 @@ ng_package( entry_point = "modules/store/index.js", packages = [ "//modules/store/migrations:npm_package", + "//modules/store/schematics:npm_package", "//modules/store/schematics-core:npm_package", ], deps = [ diff --git a/modules/store/package.json b/modules/store/package.json index 7bf494f012..cebf94b2f5 100644 --- a/modules/store/package.json +++ b/modules/store/package.json @@ -9,7 +9,10 @@ "keywords": [ "RxJS", "Angular", - "Redux" + "Redux", + "NgRx", + "Schematics", + "Angular CLI" ], "author": "NgRx", "license": "MIT", @@ -21,6 +24,7 @@ "@angular/core": "NG_VERSION", "rxjs": "RXJS_VERSION" }, + "schematics": "MODULE_SCHEMATICS_COLLECTION", "ng-update": { "packageGroup": "NG_UPDATE_PACKAGE_GROUP", "migrations": "NG_UPDATE_MIGRATIONS" diff --git a/modules/store/schematics-core/index.ts b/modules/store/schematics-core/index.ts index b148524ec4..f7b020f0da 100644 --- a/modules/store/schematics-core/index.ts +++ b/modules/store/schematics-core/index.ts @@ -70,4 +70,9 @@ export const stringUtils = { }; export { updatePackage } from './utility/update'; + export { parseName } from './utility/parse-name'; + +export { addPackageToPackageJson } from './utility/package'; + +export { platformVersion } from './utility/libs-version'; diff --git a/modules/store/schematics-core/utility/libs-version.ts b/modules/store/schematics-core/utility/libs-version.ts new file mode 100644 index 0000000000..a52bafee3e --- /dev/null +++ b/modules/store/schematics-core/utility/libs-version.ts @@ -0,0 +1 @@ +export const platformVersion = '^6.0.0'; diff --git a/modules/store/schematics-core/utility/package.ts b/modules/store/schematics-core/utility/package.ts new file mode 100644 index 0000000000..ecbc74b463 --- /dev/null +++ b/modules/store/schematics-core/utility/package.ts @@ -0,0 +1,27 @@ +import { Tree } from '@angular-devkit/schematics'; + +/** + * Adds a package to the package.json + */ +export function addPackageToPackageJson( + host: Tree, + type: string, + pkg: string, + version: string +): Tree { + if (host.exists('package.json')) { + const sourceText = host.read('package.json')!.toString('utf-8'); + const json = JSON.parse(sourceText); + if (!json[type]) { + json[type] = {}; + } + + if (!json[type][pkg]) { + json[type][pkg] = version; + } + + host.overwrite('package.json', JSON.stringify(json, null, 2)); + } + + return host; +} diff --git a/modules/store/schematics/BUILD b/modules/store/schematics/BUILD new file mode 100644 index 0000000000..5b548573eb --- /dev/null +++ b/modules/store/schematics/BUILD @@ -0,0 +1,33 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ts_library", "npm_package") + +ts_library( + name = "schematics", + srcs = glob( + [ + "**/*.ts", + ], + exclude = [ + "**/*.spec.ts", + "**/files/**/*", + ], + ), + module_name = "@ngrx/store/schematics", + deps = [ + "//modules/store/schematics-core", + ], +) + +npm_package( + name = "npm_package", + srcs = [ + ":collection.json", + ] + glob([ + "**/files/**/*", + "**/schema.json", + ]), + deps = [ + ":schematics", + ], +) diff --git a/modules/store/schematics/collection.json b/modules/store/schematics/collection.json new file mode 100644 index 0000000000..a94b72bfb2 --- /dev/null +++ b/modules/store/schematics/collection.json @@ -0,0 +1,10 @@ +{ + "schematics": { + "ng-add": { + "aliases": ["init"], + "factory": "./ng-add", + "schema": "./ng-add/schema.json", + "description": "Adds initial setup for state managment" + } + } +} diff --git a/modules/store/schematics/ng-add/files/__path__/__statePath__/index.ts b/modules/store/schematics/ng-add/files/__path__/__statePath__/index.ts new file mode 100644 index 0000000000..3051836e0a --- /dev/null +++ b/modules/store/schematics/ng-add/files/__path__/__statePath__/index.ts @@ -0,0 +1,19 @@ +import { + ActionReducer, + ActionReducerMap, + createFeatureSelector, + createSelector, + MetaReducer +} from '@ngrx/store'; +import { environment } from '<%= environmentsPath %>'; + +export interface <%= classify(stateInterface) %> { + +} + +export const reducers: ActionReducerMap<<%= classify(stateInterface) %>> = { + +}; + + +export const metaReducers: MetaReducer<<%= classify(stateInterface) %>>[] = !environment.production ? [] : []; diff --git a/modules/store/schematics/ng-add/index.spec.ts b/modules/store/schematics/ng-add/index.spec.ts new file mode 100644 index 0000000000..8da20a2644 --- /dev/null +++ b/modules/store/schematics/ng-add/index.spec.ts @@ -0,0 +1,127 @@ +import { + SchematicTestRunner, + UnitTestTree, +} from '@angular-devkit/schematics/testing'; +import { getFileContent } from '@schematics/angular/utility/test'; +import * as path from 'path'; +import { Schema as RootStoreOptions } from './schema'; +import { + getTestProjectPath, + createWorkspace, +} from '../../../schematics-core/testing'; + +describe('Store ng-add Schematic', () => { + const schematicRunner = new SchematicTestRunner( + '@ngrx/store', + path.join(__dirname, '../collection.json') + ); + const defaultOptions: RootStoreOptions = { + name: 'foo', + skipPackageJson: false, + project: 'bar', + module: undefined, + }; + + const projectPath = getTestProjectPath(); + + let appTree: UnitTestTree; + + beforeEach(() => { + appTree = createWorkspace(schematicRunner, appTree); + }); + + it('should update package.json', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const packageJson = JSON.parse(getFileContent(tree, '/package.json')); + + expect(packageJson.dependencies['@ngrx/store']).toBeDefined(); + }); + + it('should skip package.json update', () => { + const options = { ...defaultOptions, skipPackageJson: true }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const packageJson = JSON.parse(getFileContent(tree, '/package.json')); + + expect(packageJson.dependencies['@ngrx/store']).toBeUndefined(); + }); + + it('should create the initial store setup', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const files = tree.files; + expect( + files.indexOf(`${projectPath}/src/app/reducers/index.ts`) + ).toBeGreaterThanOrEqual(0); + }); + + it('should not be provided by default', () => { + const options = { ...defaultOptions }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent(`${projectPath}/src/app/app.module.ts`); + expect(content).not.toMatch( + /import { reducers, metaReducers } from '\.\/reducers';/ + ); + }); + + it('should import into a specified module', () => { + const options = { ...defaultOptions, module: 'app.module.ts' }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent(`${projectPath}/src/app/app.module.ts`); + expect(content).toMatch( + /import { reducers, metaReducers } from '\.\/reducers';/ + ); + }); + + it('should import the environments correctly', () => { + const options = { ...defaultOptions, module: 'app.module.ts' }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent( + `${projectPath}/src/app/reducers/index.ts` + ); + expect(content).toMatch( + /import { environment } from '..\/..\/environments\/environment';/ + ); + }); + + it('should fail if specified module does not exist', () => { + const options = { ...defaultOptions, module: '/src/app/app.moduleXXX.ts' }; + let thrownError: Error | null = null; + try { + schematicRunner.runSchematic('ng-add', options, appTree); + } catch (err) { + thrownError = err; + } + expect(thrownError).toBeDefined(); + }); + + it('should support a default root state interface name', () => { + const options = { ...defaultOptions, name: 'State' }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent( + `${projectPath}/src/app/reducers/index.ts` + ); + expect(content).toMatch(/export interface State {/); + }); + + it('should support a custom root state interface name', () => { + const options = { + ...defaultOptions, + name: 'State', + stateInterface: 'AppState', + }; + + const tree = schematicRunner.runSchematic('ng-add', options, appTree); + const content = tree.readContent( + `${projectPath}/src/app/reducers/index.ts` + ); + expect(content).toMatch(/export interface AppState {/); + }); +}); diff --git a/modules/store/schematics/ng-add/index.ts b/modules/store/schematics/ng-add/index.ts new file mode 100644 index 0000000000..84d3b94b1e --- /dev/null +++ b/modules/store/schematics/ng-add/index.ts @@ -0,0 +1,147 @@ +import { + Rule, + SchematicContext, + SchematicsException, + Tree, + apply, + branchAndMerge, + chain, + filter, + mergeWith, + template, + url, + noop, +} from '@angular-devkit/schematics'; +import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks'; +import { + InsertChange, + addImportToModule, + buildRelativePath, + findModuleFromOptions, + getProjectPath, + insertImport, + stringUtils, + addPackageToPackageJson, + platformVersion, + parseName, +} from '@ngrx/store/schematics-core'; +import { Path, dirname } from '@angular-devkit/core'; +import * as ts from 'typescript'; +import { Schema as RootStoreOptions } from './schema'; + +function addImportToNgModule(options: RootStoreOptions): Rule { + return (host: Tree) => { + const modulePath = options.module; + + if (!modulePath) { + return host; + } + + if (!host.exists(modulePath)) { + throw new Error('Specified module does not exist'); + } + + const text = host.read(modulePath); + if (text === null) { + throw new SchematicsException(`File ${modulePath} does not exist.`); + } + const sourceText = text.toString('utf-8'); + + const source = ts.createSourceFile( + modulePath, + sourceText, + ts.ScriptTarget.Latest, + true + ); + + const statePath = `${options.path}/${options.statePath}`; + const relativePath = buildRelativePath(modulePath, statePath); + const srcPath = dirname(options.path as Path); + const environmentsPath = buildRelativePath( + statePath, + `/${srcPath}/environments/environment` + ); + + const changes = [ + insertImport(source, modulePath, 'StoreModule', '@ngrx/store'), + insertImport(source, modulePath, 'reducers, metaReducers', relativePath), + addImportToModule( + source, + modulePath, + 'StoreModule.forRoot(reducers, { metaReducers })', + relativePath + ), + ]; + const recorder = host.beginUpdate(modulePath); + + for (const change of changes) { + if (change instanceof InsertChange) { + recorder.insertLeft(change.pos, change.toAdd); + } + } + host.commitUpdate(recorder); + + return host; + }; +} + +function addNgRxStoreToPackageJson() { + return (host: Tree, context: SchematicContext) => { + addPackageToPackageJson( + host, + 'dependencies', + '@ngrx/store', + platformVersion + ); + context.addTask(new NodePackageInstallTask()); + return host; + }; +} + +export default function(options: RootStoreOptions): Rule { + return (host: Tree, context: SchematicContext) => { + options.path = getProjectPath(host, options); + + const parsedPath = parseName(options.path, options.name); + options.name = parsedPath.name; + options.path = parsedPath.path; + + const statePath = `/${options.path}/${options.statePath}/index.ts`; + const srcPath = dirname(options.path as Path); + const environmentsPath = buildRelativePath( + statePath, + `/${srcPath}/environments/environment` + ); + + if (options.module) { + options.module = findModuleFromOptions(host, options); + } + + if (options.stateInterface && options.stateInterface !== 'State') { + options.stateInterface = stringUtils.classify(options.stateInterface); + } + + const templateSource = apply(url('./files'), [ + template({ + ...stringUtils, + ...(options as object), + environmentsPath, + } as any), + ]); + + return chain([ + options && options.skipPackageJson ? noop() : addNgRxStoreToPackageJson(), + branchAndMerge( + chain([ + filter( + path => + path.endsWith('.module.ts') && + !path.endsWith('-routing.module.ts') + ), + addImportToNgModule(options), + mergeWith(templateSource), + ]) + ), + ])(host, context); + }; +} diff --git a/modules/store/schematics/ng-add/schema.json b/modules/store/schematics/ng-add/schema.json new file mode 100644 index 0000000000..b4f7016859 --- /dev/null +++ b/modules/store/schematics/ng-add/schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsNgRxRootState", + "title": "NgRx Root State Management Options Schema", + "type": "object", + "properties": { + "name": { + "description": "The name of the state.", + "type": "string", + "$default": { + "$source": "argv", + "index": 0 + } + }, + "skipPackageJson": { + "type": "boolean", + "default": false, + "description": + "Do not add @ngrx/store as dependency to package.json (e.g., --skipPackageJson)." + }, + "path": { + "type": "string", + "format": "path", + "description": "The path to create the component.", + "visible": false + }, + "module": { + "type": "string", + "default": "", + "description": "Allows specification of the declaring module.", + "alias": "m", + "subtype": "filepath" + }, + "statePath": { + "type": "string", + "default": "reducers" + }, + "stateInterface": { + "type": "string", + "default": "State", + "description": "Specifies the interface for the state.", + "alias": "si" + } + }, + "required": [] +} diff --git a/modules/store/schematics/ng-add/schema.ts b/modules/store/schematics/ng-add/schema.ts new file mode 100644 index 0000000000..3f9671fc19 --- /dev/null +++ b/modules/store/schematics/ng-add/schema.ts @@ -0,0 +1,9 @@ +export interface Schema { + name: string; + skipPackageJson?: boolean; + path?: string; + project?: string; + module?: string; + statePath?: string; + stateInterface?: string; +} diff --git a/package.json b/package.json index 6d7db8464c..81b3113288 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "**/*.spec", "**/spec/**/*", "/modules/schematics/src/*/files/**/*", + "/modules/**/schematics/**/files/**/*", "**/schematics/src/utility/*" ], "include": [ diff --git a/tools/defaults.bzl b/tools/defaults.bzl index 21355ab0bb..47202ddf8f 100644 --- a/tools/defaults.bzl +++ b/tools/defaults.bzl @@ -9,6 +9,7 @@ NG_VERSION = "^6.0.0" RXJS_VERSION = "^5.6.0-forward-compat.0 || ^6.0.0" NG_DEVKIT_VERSION = "^0.6.0" NG_UPDATE_MIGRATIONS = "./migrations/migration.json" +MODULE_SCHEMATICS_COLLECTION = "./schematics/collection.json" NGRX_SCOPED_PACKAGES = ["@ngrx/%s" % p for p in [ "store", @@ -81,4 +82,4 @@ def npm_package(name, replacements = {}, **kwargs): _npm_package( name = name, replacements = dict(replacements, **PKG_GROUP_REPLACEMENTS), - **kwargs) \ No newline at end of file + **kwargs)