Thomas Burleson 04e99b06ae fix(schematics): ngrx schematics should generate enhanced ngrx files
@nrwl/schematics no longer uses the @ngrx/schematics to generate NgRx feature files.
*  `ngrx/files/__directory__` templates are used
*  Templates replicate the simple outputs generated from @ngrx/schematics:feature
*  Templates add significant Nx enhancements.

The following standard files will be scaffolded:
* `<feature>.actions.ts`
* `<feature>.effects.ts` + `<feature>.effects.spec.ts`
* `<feature>.reducer.ts` + `<feature>.reducer.spec.ts`

The following new files will also be scaffolded:
* `<feature>.selectors.ts` +  `<feature>.selectors.spec.ts`

Changes include:

* Change the action/enums to generate a trio of enums for each *feature*: `Load<Feature>`, `<Feature>Loaded`, and `<Feature>LoadError`
* Add code generators for `<feature>.selectors.ts`
* Add code generators for unit and integration testing `*.spec.ts` files
* Update the public barrel [`index.ts`] when adding ngrx to a library
* Use `StoreModule.forFeature()` when adding ngrx feature (without using the `--root` option)
* Use the Effect to respond tp `load<Feature>$` and dispatch `<Feature>Loaded` or `<Feature>LoadError`
* Update the Action to export `<feature>Actions` map of all action classes
* fix `ng-add.test.ts` tests for latest Angular CLI scaffolding
* fix `application.spec.ts` expect fails

Fixes #472,  Fixes #618,  Fixes #317,  Fixes #561, Refs #380.
2018-07-21 21:13:48 -04:00

731 lines
20 KiB
TypeScript
Executable File

import {
chain,
Rule,
SchematicContext,
Tree,
url,
mergeWith,
apply,
template
} from '@angular-devkit/schematics';
import { Schema } from './schema';
import * as path from 'path';
import { join } from 'path';
import {
angularCliVersion,
ngrxStoreFreezeVersion,
ngrxVersion,
nxVersion,
prettierVersion,
routerStoreVersion,
schematicsVersion,
jasmineMarblesVersion,
ngrxSchematicsVersion,
rxjsVersion
} from '../../lib-versions';
import * as fs from 'fs';
import * as ts from 'typescript';
import {
offsetFromRoot,
resolveUserExistingPrettierConfig,
DEFAULT_NRWL_PRETTIER_CONFIG
} from '../../utils/common';
import {
updateJsonFile,
serializeJson,
renameSync
} from '../../utils/fileutils';
import { toFileName } from '../../utils/name-utils';
import {
updateJsonInTree,
readJsonInTree,
addImportToModule
} from '../../utils/ast-utils';
import { editTarget } from '../../utils/cli-config-utils';
import { from } from 'rxjs';
import { tap, mapTo } from 'rxjs/operators';
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
import { getAppModulePath } from '@schematics/angular/utility/ng-ast-utils';
import { insertImport } from '@schematics/angular/utility/route-utils';
import { InsertChange } from '@schematics/angular/utility/change';
function updatePackageJson() {
return updateJsonInTree('package.json', packageJson => {
packageJson.scripts = packageJson.scripts || {};
packageJson.scripts = {
...packageJson.scripts,
'affected:apps': './node_modules/.bin/nx affected:apps',
'affected:libs': './node_modules/.bin/nx affected:libs',
'affected:build': './node_modules/.bin/nx affected:build',
'affected:e2e': './node_modules/.bin/nx affected:e2e',
'affected:test': './node_modules/.bin/nx affected:test',
'affected:lint': './node_modules/.bin/nx affected:lint',
'affected:dep-graph': './node_modules/.bin/nx affected:dep-graph',
format: './node_modules/.bin/nx format:write',
'format:write': './node_modules/.bin/nx format:write',
'format:check': './node_modules/.bin/nx format:check',
update: 'ng update @nrwl/schematics',
'update:check': 'ng update',
lint: './node_modules/.bin/nx lint && ng lint',
'dep-graph': './node_modules/.bin/nx dep-graph',
'workspace-schematic': './node_modules/.bin/nx workspace-schematic',
help: './node_modules/.bin/nx help'
};
packageJson.devDependencies = packageJson.devDependencies || {};
if (!packageJson.dependencies) {
packageJson.dependencies = {};
}
if (!packageJson.scripts) {
packageJson.scripts = {};
}
if (!packageJson.dependencies['@nrwl/nx']) {
packageJson.dependencies['@nrwl/nx'] = nxVersion;
}
if (!packageJson.dependencies['@ngrx/store']) {
packageJson.dependencies['@ngrx/store'] = ngrxVersion;
}
if (!packageJson.dependencies['@ngrx/router-store']) {
packageJson.dependencies['@ngrx/router-store'] = routerStoreVersion;
}
if (!packageJson.dependencies['@ngrx/effects']) {
packageJson.dependencies['@ngrx/effects'] = ngrxVersion;
}
if (!packageJson.dependencies['@ngrx/store-devtools']) {
packageJson.dependencies['@ngrx/store-devtools'] = ngrxVersion;
}
if (!packageJson.dependencies['ngrx-store-freeze']) {
packageJson.dependencies['ngrx-store-freeze'] = ngrxStoreFreezeVersion;
}
if (!packageJson.dependencies['rxjs-compat']) {
packageJson.dependencies['rxjs-compat'] = rxjsVersion;
}
if (!packageJson.devDependencies['@nrwl/schematics']) {
packageJson.devDependencies['@nrwl/schematics'] = schematicsVersion;
}
if (!packageJson.devDependencies['@angular/cli']) {
packageJson.devDependencies['@angular/cli'] = angularCliVersion;
}
packageJson.devDependencies['karma'] = '~2.0.0';
if (!packageJson.devDependencies['jasmine-marbles']) {
packageJson.devDependencies['jasmine-marbles'] = jasmineMarblesVersion;
}
if (!packageJson.devDependencies['prettier']) {
packageJson.devDependencies['prettier'] = prettierVersion;
}
return packageJson;
});
}
function convertPath(name: string, originalPath: string) {
return `apps/${name}/${originalPath}`;
}
function updateAngularCLIJson(options: Schema): Rule {
return updateJsonInTree('angular.json', angularJson => {
angularJson = {
...angularJson,
newProjectRoot: '',
cli: {
defaultCollection: '@nrwl/schematics'
}
};
let app = angularJson.projects[options.name];
let e2eProject = getE2eProject(angularJson);
const oldSourceRoot = app.sourceRoot;
app = {
...app,
root: path.join('apps', options.name),
sourceRoot: convertPath(options.name, app.sourceRoot)
};
const buildConfig = app.architect.build;
buildConfig.options = {
...buildConfig.options,
outputPath: path.join('dist/apps', options.name),
index: convertPath(options.name, buildConfig.options.index),
main: convertPath(options.name, buildConfig.options.main),
polyfills: convertPath(options.name, buildConfig.options.polyfills),
tsConfig: path.join(app.root, getFilename(buildConfig.options.tsConfig)),
assets: buildConfig.options.assets.map(
asset =>
asset.startsWith(oldSourceRoot)
? convertPath(options.name, asset)
: asset
),
styles: buildConfig.options.styles.map(
style =>
style.startsWith(oldSourceRoot)
? convertPath(options.name, style)
: style
),
scripts: buildConfig.options.scripts.map(
script =>
script.startsWith(oldSourceRoot)
? convertPath(options.name, script)
: script
)
};
buildConfig.configurations.production.fileReplacements = buildConfig.configurations.production.fileReplacements.map(
replacement => {
return {
replace: convertPath(options.name, replacement.replace),
with: convertPath(options.name, replacement.with)
};
}
);
const serveConfig = app.architect.serve;
serveConfig.options.browserTarget = editTarget(
serveConfig.options.browserTarget,
parsedTarget => {
return {
...parsedTarget,
project: options.name
};
}
);
serveConfig.configurations.production.browserTarget = editTarget(
serveConfig.configurations.production.browserTarget,
parsedTarget => {
return {
...parsedTarget,
project: options.name
};
}
);
const i18nConfig = app.architect['extract-i18n'];
i18nConfig.options.browserTarget = editTarget(
i18nConfig.options.browserTarget,
parsedTarget => {
return {
...parsedTarget,
project: options.name
};
}
);
const testConfig = app.architect.test;
testConfig.options = {
...testConfig.options,
main: convertPath(options.name, testConfig.options.main),
polyfills: convertPath(options.name, testConfig.options.polyfills),
tsConfig: path.join(app.root, getFilename(testConfig.options.tsConfig)),
karmaConfig: path.join(
app.root,
getFilename(testConfig.options.karmaConfig)
),
assets: testConfig.options.assets.map(
asset =>
asset.startsWith(oldSourceRoot)
? convertPath(options.name, asset)
: asset
),
styles: testConfig.options.styles.map(
style =>
style.startsWith(oldSourceRoot)
? convertPath(options.name, style)
: style
),
scripts: testConfig.options.scripts.map(
script =>
script.startsWith(oldSourceRoot)
? convertPath(options.name, script)
: script
)
};
const lintConfig = app.architect.lint;
lintConfig.options = {
...lintConfig.options,
tsConfig: [buildConfig.options.tsConfig, testConfig.options.tsConfig]
};
if (app.architect.server) {
const serverConfig = app.architect.server;
serverConfig.options = {
...serverConfig.options,
outputPath: path.join('dist/apps', options.name + '-server'),
main: convertPath(options.name, serverConfig.options.main),
tsConfig: path.join(
app.root,
getFilename(serverConfig.options.tsConfig)
)
};
}
angularJson.projects[options.name] = app;
if (e2eProject) {
e2eProject.root = path.join('apps', getE2eKey(angularJson));
const e2eConfig = e2eProject.architect.e2e;
e2eConfig.options = {
...e2eConfig.options,
protractorConfig: path.join(
e2eProject.root,
getFilename(e2eConfig.options.protractorConfig)
)
};
e2eConfig.options.devServerTarget = editTarget(
e2eConfig.options.devServerTarget,
parsedTarget => {
return {
...parsedTarget,
project: options.name
};
}
);
const e2eLintConfig = e2eProject.architect.lint;
e2eLintConfig.options.tsConfig = path.join(
e2eProject.root,
getFilename(e2eLintConfig.options.tsConfig)
);
angularJson.projects[getE2eKey(angularJson)] = e2eProject;
}
return angularJson;
});
}
function updateTsConfig(options: Schema): Rule {
return updateJsonInTree('tsconfig.json', tsConfigJson =>
setUpCompilerOptions(tsConfigJson, options.npmScope, '')
);
}
function parseLoadChildren(loadChildrenString: string) {
const [path, className] = loadChildrenString.split('#');
return {
path,
className
};
}
function serializeLoadChildren({
path,
className
}: {
path: string;
className: string;
}) {
return `${path}#${className}`;
}
function updateTsConfigsJson(options: Schema) {
return (host: Tree) => {
const angularJson = readJsonInTree(host, 'angular.json');
const app = angularJson.projects[options.name];
const e2eProject = getE2eProject(angularJson);
// This has to stay using fs since it is created with fs
const offset = '../../';
updateJsonFile(`${app.root}/tsconfig.app.json`, json => {
json.extends = `${offset}tsconfig.json`;
json.compilerOptions.outDir = `${offset}dist/out-tsc/apps/${
options.name
}`;
});
// This has to stay using fs since it is created with fs
updateJsonFile(`${app.root}/tsconfig.spec.json`, json => {
json.extends = `${offset}tsconfig.json`;
json.compilerOptions.outDir = `${offset}dist/out-tsc/apps/${
options.name
}`;
if (json.files) {
json.files = json.files.map(file =>
path.join(path.relative(app.root, app.sourceRoot), file)
);
}
});
if (app.architect.server) {
updateJsonFile(`${app.root}/tsconfig.server.json`, json => {
json.compilerOptions.outDir = `${offset}dist/out-tsc/apps/${
options.name
}-server`;
const loadChildrenConfig = parseLoadChildren(
json.angularCompilerOptions.entryModule
);
loadChildrenConfig.path = path.join('src', loadChildrenConfig.path);
json.angularCompilerOptions = {
...json.angularCompilerOptions,
entryModule: serializeLoadChildren(loadChildrenConfig)
};
});
}
if (e2eProject) {
// This has to stay using fs since it is created with fs
updateJsonFile(`${e2eProject.root}/tsconfig.e2e.json`, json => {
json.extends = `${offsetFromRoot(e2eProject.root)}tsconfig.json`;
json.compilerOptions = {
...json.compilerOptions,
outDir: `${offsetFromRoot(e2eProject.root)}dist/out-tsc/${
e2eProject.root
}`
};
});
}
return host;
};
}
function updateTsLint() {
return updateJsonInTree('tslint.json', tslintJson => {
[
'no-trailing-whitespace',
'one-line',
'quotemark',
'typedef-whitespace',
'whitespace'
].forEach(key => {
tslintJson[key] = undefined;
});
tslintJson.rulesDirectory = tslintJson.rulesDirectory || [];
tslintJson.rulesDirectory.push('node_modules/@nrwl/schematics/src/tslint');
tslintJson['nx-enforce-module-boundaries'] = [
true,
{
allow: [],
depConstraints: [{ sourceTag: '*', onlyDependOnLibsWithTags: ['*'] }]
}
];
return tslintJson;
});
}
function updateProjectTsLint(options: Schema) {
return (host: Tree) => {
const angularJson = readJsonInTree(host, '/angular.json');
const app = angularJson.projects[options.name];
const offset = '../../';
updateJsonFile(`${app.root}/tslint.json`, json => {
json.extends = `${offset}tslint.json`;
});
return host;
};
}
function setUpCompilerOptions(
tsconfig: any,
npmScope: string,
offset: string
): any {
if (!tsconfig.compilerOptions.paths) {
tsconfig.compilerOptions.paths = {};
}
tsconfig.compilerOptions.baseUrl = '.';
tsconfig.compilerOptions.paths[`@${npmScope}/*`] = [`${offset}libs/*`];
return tsconfig;
}
function moveOutOfSrc(
sourceRoot: string,
appName: string,
filename: string,
context?: SchematicContext
) {
const from = path.join(sourceRoot, filename);
const to = path.join('apps', appName, filename);
renameSync(from, to, err => {
if (!context) {
return;
} else if (!err) {
context.logger.info(`Renamed ${from} -> ${to}`);
} else {
context.logger.warn(err.message);
}
});
}
function getFilename(path: string) {
return path.split('/').pop();
}
function getE2eKey(angularJson: any) {
return Object.keys(angularJson.projects).find(key => {
return !!angularJson.projects[key].architect.e2e;
});
}
function getE2eProject(angularJson: any) {
const key = getE2eKey(angularJson);
if (key) {
return angularJson.projects[key];
} else {
return null;
}
}
function moveExistingFiles(options: Schema) {
return (host: Tree, context: SchematicContext) => {
const angularJson = readJsonInTree(host, 'angular.json');
const app = angularJson.projects[options.name];
const e2eApp = getE2eProject(angularJson);
// No context is passed because it should not be required to have a browserslist
moveOutOfSrc(app.sourceRoot, options.name, 'browserslist');
moveOutOfSrc(
app.sourceRoot,
options.name,
getFilename(app.architect.test.options.karmaConfig),
context
);
moveOutOfSrc(
app.sourceRoot,
options.name,
getFilename(app.architect.build.options.tsConfig),
context
);
moveOutOfSrc(
app.sourceRoot,
options.name,
getFilename(app.architect.test.options.tsConfig),
context
);
if (app.architect.server) {
moveOutOfSrc(
app.sourceRoot,
options.name,
getFilename(app.architect.server.options.tsConfig),
context
);
}
moveOutOfSrc(app.sourceRoot, options.name, 'tslint.json', context);
const oldAppSourceRoot = app.sourceRoot;
const newAppSourceRoot = join('apps', options.name, app.sourceRoot);
renameSync(oldAppSourceRoot, newAppSourceRoot, err => {
if (!err) {
context.logger.info(
`Renamed ${oldAppSourceRoot} -> ${newAppSourceRoot}`
);
} else {
context.logger.error(err.message);
throw err;
}
});
if (e2eApp) {
const oldE2eRoot = e2eApp.root;
const newE2eRoot = join('apps', getE2eKey(angularJson));
renameSync(oldE2eRoot, newE2eRoot, err => {
if (!err) {
context.logger.info(`Renamed ${oldE2eRoot} -> ${newE2eRoot}`);
} else {
context.logger.error(err.message);
throw err;
}
});
} else {
context.logger.warn(
'No e2e project was migrated because there was none declared in angular.json'
);
}
return host;
};
}
function createAdditionalFiles(options: Schema): Rule {
return (host: Tree, _context: SchematicContext) => {
const angularJson = readJsonInTree(host, 'angular.json');
host.create(
'nx.json',
serializeJson({
npmScope: options.npmScope,
implicitDependencies: {
'angular.json': '*',
'package.json': '*',
'tsconfig.json': '*',
'tslint.json': '*',
'nx.json': '*'
},
projects: {
[options.name]: {
tags: []
},
[getE2eKey(angularJson)]: {
tags: []
}
}
})
);
host.create('libs/.gitkeep', '');
// if the user does not already have a prettier configuration
// of any kind, create one
return from(resolveUserExistingPrettierConfig()).pipe(
tap(existingPrettierConfig => {
if (!existingPrettierConfig) {
host.create(
'.prettierrc',
serializeJson(DEFAULT_NRWL_PRETTIER_CONFIG)
);
}
}),
mapTo(host)
);
};
}
function dedup(array: any[]): any[] {
const res = [];
array.forEach(a => {
if (res.indexOf(a) === -1) {
res.push(a);
}
});
return res;
}
function insertInToString(originalString: string, pos: number, toAdd: string) {
return originalString.slice(0, pos) + toAdd + originalString.slice(pos);
}
function addNxModule(options: Schema) {
return (host: Tree, context: SchematicContext) => {
const angularJson = readJsonInTree(host, 'angular.json');
const app = angularJson.projects[options.name];
const modulePath = path.resolve(
'.',
getAppModulePath(host, app.architect.build.options.main).slice(1)
);
let content = fs.readFileSync(modulePath).toString();
// Bail if the module already cotains the import
if (content.includes('NxModule.forRoot()')) {
return host;
}
let moduleSource = ts.createSourceFile(
modulePath,
content,
ts.ScriptTarget.Latest,
true
);
const importChange: InsertChange = insertImport(
moduleSource,
modulePath,
'NxModule',
'@nrwl/nx'
) as InsertChange;
content = insertInToString(content, importChange.pos, importChange.toAdd);
moduleSource = ts.createSourceFile(
modulePath,
content,
ts.ScriptTarget.Latest,
true
);
const ngModuleChange: InsertChange = addImportToModule(
moduleSource,
modulePath,
'NxModule.forRoot()'
)[0] as InsertChange;
content = insertInToString(
content,
ngModuleChange.pos,
ngModuleChange.toAdd
);
fs.writeFileSync(modulePath, content);
return host;
};
}
function checkCanConvertToWorkspace(options: Schema) {
return (host: Tree, context: SchematicContext) => {
try {
if (!host.exists('package.json')) {
throw new Error('Cannot find package.json');
}
// TODO: This restriction should be lited
const angularJson = readJsonInTree(host, 'angular.json');
if (Object.keys(angularJson.projects).length > 2) {
throw new Error('Can only convert projects with one app');
}
const e2eKey = getE2eKey(angularJson);
const e2eApp = getE2eProject(angularJson);
if (
e2eApp &&
!host.exists(e2eApp.architect.e2e.options.protractorConfig)
) {
context.logger.info(
`Make sure the ${e2eKey}.architect.e2e.options.protractorConfig is valid or the ${e2eKey} project is removed from angular.json.`
);
throw new Error(
`An e2e project was specified but ${
e2eApp.architect.e2e.options.protractorConfig
} could not be found.`
);
}
return host;
} catch (e) {
context.logger.error(e.message);
context.logger.error(
'Your workspace could not be converted into an Nx Workspace because of the above error.'
);
throw new Error();
}
};
}
function addInstallTask(options: Schema) {
return (host: Tree, context: SchematicContext) => {
if (!options.skipInstall) {
context.addTask(new NodePackageInstallTask());
}
return host;
};
}
export default function(schema: Schema): Rule {
const options = {
...schema,
name: toFileName(schema.name),
npmScope: toFileName(schema.npmScope || schema.name)
};
const templateSource = apply(url('./files'), [
template({
tmpl: ''
})
]);
return chain([
checkCanConvertToWorkspace(options),
mergeWith(templateSource),
moveExistingFiles(options),
createAdditionalFiles(options),
updatePackageJson(),
updateAngularCLIJson(options),
updateTsLint(),
updateProjectTsLint(options),
updateTsConfig(options),
updateTsConfigsJson(options),
addNxModule(options),
addInstallTask(options)
]);
}