diff --git a/packages/devkit/index.ts b/packages/devkit/index.ts index 7cfdb07d72..962f38af32 100644 --- a/packages/devkit/index.ts +++ b/packages/devkit/index.ts @@ -3,6 +3,7 @@ export { WorkspaceJsonConfiguration, TargetConfiguration, ProjectConfiguration, + ProjectType, Generator, GeneratorCallback, Executor, @@ -30,6 +31,7 @@ export { } from './src/generators/project-configuration'; export { toJS } from './src/generators/to-js'; export { visitNotIgnoredFiles } from './src/generators/visit-not-ignored-files'; +export { setDefaultCollection } from './src/generators/set-default-collection'; export { parseTargetString } from './src/executors/parse-target-string'; export { readTargetOptions } from './src/executors/read-target-options'; diff --git a/packages/devkit/src/generators/set-default-collection.ts b/packages/devkit/src/generators/set-default-collection.ts new file mode 100644 index 0000000000..63621af036 --- /dev/null +++ b/packages/devkit/src/generators/set-default-collection.ts @@ -0,0 +1,26 @@ +import { Tree } from '@nrwl/tao/src/shared/tree'; +import { + readWorkspaceConfiguration, + updateWorkspaceConfiguration, +} from './project-configuration'; + +/** + * Sets the default collection within the workspace. + * + * Will only set the defaultCollection if one does not exist or if it is not `@nrwl/workspace` + * + * @param host + * @param collectionName Name of the collection to be set as the default + */ +export function setDefaultCollection(host: Tree, collectionName: string) { + const workspace = readWorkspaceConfiguration(host); + workspace.cli = workspace.cli || {}; + + const defaultCollection = workspace.cli.defaultCollection; + + if (!defaultCollection || defaultCollection === '@nrwl/workspace') { + workspace.cli.defaultCollection = collectionName; + } + + updateWorkspaceConfiguration(host, workspace); +} diff --git a/packages/devkit/src/tests/create-tree-with-empty-workspace.ts b/packages/devkit/src/tests/create-tree-with-empty-workspace.ts index b679406a5e..ebf41761ef 100644 --- a/packages/devkit/src/tests/create-tree-with-empty-workspace.ts +++ b/packages/devkit/src/tests/create-tree-with-empty-workspace.ts @@ -7,6 +7,7 @@ export function createTreeWithEmptyWorkspace() { const tree = new FsTree('/virtual', false); tree.write('/workspace.json', JSON.stringify({ version: 1, projects: {} })); + tree.write('./.prettierrc', '{"singleQuote": true}'); tree.write( '/package.json', JSON.stringify({ diff --git a/packages/next/src/schematics/application/lib/update-jest-config.ts b/packages/next/src/schematics/application/lib/update-jest-config.ts index bae8e27cd6..2a7ba4fa44 100644 --- a/packages/next/src/schematics/application/lib/update-jest-config.ts +++ b/packages/next/src/schematics/application/lib/update-jest-config.ts @@ -1,6 +1,20 @@ -import { chain, noop, Rule } from '@angular-devkit/schematics'; +import { chain, noop, Rule, Tree } from '@angular-devkit/schematics'; import { NormalizedSchema } from './normalize-options'; -import { updateBabelJestConfig } from '@nrwl/react/src/rules/update-babel-jest-config'; +import { updateJsonInTree } from '@nrwl/workspace'; + +type BabelJestConfigUpdater = (json: T) => T; + +function updateBabelJestConfigOriginal( + projectRoot: string, + update: BabelJestConfigUpdater +) { + return (host: Tree) => { + const configPath = `${projectRoot}/babel-jest.config.json`; + return host.exists(configPath) + ? updateJsonInTree(configPath, update) + : noop(); + }; +} export function updateJestConfig(options: NormalizedSchema): Rule { return options.unitTestRunner === 'none' @@ -15,7 +29,7 @@ export function updateJestConfig(options: NormalizedSchema): Rule { ); host.overwrite(configPath, content); }, - updateBabelJestConfig(options.appProjectRoot, (json) => { + updateBabelJestConfigOriginal(options.appProjectRoot, (json) => { if (options.style === 'styled-jsx') { json.plugins = (json.plugins || []).concat('styled-jsx/babel'); } diff --git a/packages/react/collection.json b/packages/react/collection.json index 06a513800d..64023ed559 100644 --- a/packages/react/collection.json +++ b/packages/react/collection.json @@ -4,72 +4,144 @@ "extends": ["@nrwl/workspace"], "schematics": { "init": { - "factory": "./src/schematics/init/init", - "schema": "./src/schematics/init/schema.json", + "factory": "./src/generators/init/init#reactInitSchematic", + "schema": "./src/generators/init/schema.json", "description": "Initialize the @nrwl/react plugin", "aliases": ["ng-add"], "hidden": true }, "application": { - "factory": "./src/schematics/application/application", - "schema": "./src/schematics/application/schema.json", + "factory": "./src/generators/application/application#applicationSchematic", + "schema": "./src/generators/application/schema.json", "aliases": ["app"], "description": "Create an application" }, "library": { - "factory": "./src/schematics/library/library", - "schema": "./src/schematics/library/schema.json", + "factory": "./src/generators/library/library#librarySchematic", + "schema": "./src/generators/library/schema.json", "aliases": ["lib"], "description": "Create a library" }, "component": { - "factory": "./src/schematics/component/component", - "schema": "./src/schematics/component/schema.json", + "factory": "./src/generators/component/component#componentSchematic", + "schema": "./src/generators/component/schema.json", "description": "Create a component", "aliases": "c" }, "redux": { - "factory": "./src/schematics/redux/redux", - "schema": "./src/schematics/redux/schema.json", + "factory": "./src/generators/redux/redux#reduxSchematic", + "schema": "./src/generators/redux/schema.json", "description": "Create a redux slice for a project", "aliases": ["slice"] }, "storybook-configuration": { - "factory": "./src/schematics/storybook-configuration/configuration", - "schema": "./src/schematics/storybook-configuration/schema.json", + "factory": "./src/generators/storybook-configuration/configuration#storybookConfigurationSchematic", + "schema": "./src/generators/storybook-configuration/schema.json", "description": "Set up storybook for a react library", "hidden": false }, "storybook-migrate-defaults-5-to-6": { - "factory": "./src/schematics/storybook-migrate-defaults-5-to-6/migrate-defaults-5-to-6", - "schema": "./src/schematics/storybook-migrate-defaults-5-to-6/schema.json", + "factory": "./src/generators/storybook-migrate-defaults-5-to-6/migrate-defaults-5-to-6#storybookMigration5to6Schematic", + "schema": "./src/generators/storybook-migrate-defaults-5-to-6/schema.json", "description": "Generate default Storybook configuration files using Storybook version >=6.x specs, for projects that already have Storybook instances and configurations of versions <6.x.", "hidden": false }, "component-story": { - "factory": "./src/schematics/component-story/component-story", - "schema": "./src/schematics/component-story/schema.json", + "factory": "./src/generators/component-story/component-story#componentStorySchematic", + "schema": "./src/generators/component-story/schema.json", "description": "Generate storybook story for a react component", "hidden": false }, "stories": { - "factory": "./src/schematics/stories/stories", - "schema": "./src/schematics/stories/schema.json", + "factory": "./src/generators/stories/stories#storiesSchematic", + "schema": "./src/generators/stories/schema.json", "description": "Create stories/specs for all components declared in a library", "hidden": false }, "component-cypress-spec": { - "factory": "./src/schematics/component-cypress-spec/component-cypress-spec", - "schema": "./src/schematics/component-cypress-spec/schema.json", + "factory": "./src/generators/component-cypress-spec/component-cypress-spec#componentCypressSchematic", + "schema": "./src/generators/component-cypress-spec/schema.json", + "description": "Create a cypress spec for a ui component that has a story", + "hidden": false + } + }, + "generators": { + "init": { + "factory": "./src/generators/init/init#reactInitGenerator", + "schema": "./src/generators/init/schema.json", + "description": "Initialize the @nrwl/react plugin", + "aliases": ["ng-add"], + "hidden": true + }, + + "application": { + "factory": "./src/generators/application/application#applicationGenerator", + "schema": "./src/generators/application/schema.json", + "aliases": ["app"], + "description": "Create an application" + }, + + "library": { + "factory": "./src/generators/library/library#libraryGenerator", + "schema": "./src/generators/library/schema.json", + "aliases": ["lib"], + "description": "Create a library" + }, + + "component": { + "factory": "./src/generators/component/component#componentGenerator", + "schema": "./src/generators/component/schema.json", + "description": "Create a component", + "aliases": "c" + }, + + "redux": { + "factory": "./src/generators/redux/redux#reduxGenerator", + "schema": "./src/generators/redux/schema.json", + "description": "Create a redux slice for a project", + "aliases": ["slice"] + }, + + "storybook-configuration": { + "factory": "./src/generators/storybook-configuration/configuration#storybookConfigurationGenerator", + "schema": "./src/generators/storybook-configuration/schema.json", + "description": "Set up storybook for a react library", + "hidden": false + }, + + "storybook-migrate-defaults-5-to-6": { + "factory": "./src/generators/storybook-migrate-defaults-5-to-6/migrate-defaults-5-to-6#storybookMigration5to6Generator", + "schema": "./src/generators/storybook-migrate-defaults-5-to-6/schema.json", + "description": "Generate default Storybook configuration files using Storybook version >=6.x specs, for projects that already have Storybook instances and configurations of versions <6.x.", + "hidden": false + }, + + "component-story": { + "factory": "./src/generators/component-story/component-story#componentStoryGenerator", + "schema": "./src/generators/component-story/schema.json", + "description": "Generate storybook story for a react component", + "hidden": false + }, + + "stories": { + "factory": "./src/generators/stories/stories#storiesGenerator", + "schema": "./src/generators/stories/schema.json", + "description": "Create stories/specs for all components declared in a library", + "hidden": false + }, + + "component-cypress-spec": { + "factory": "./src/generators/component-cypress-spec/component-cypress-spec#componentCypressGenerator", + "schema": "./src/generators/component-cypress-spec/schema.json", "description": "Create a cypress spec for a ui component that has a story", "hidden": false } diff --git a/packages/react/index.ts b/packages/react/index.ts index 191fe337d0..263801807e 100644 --- a/packages/react/index.ts +++ b/packages/react/index.ts @@ -2,12 +2,12 @@ export { extraEslintDependencies, reactEslintJson } from './src/utils/lint'; export { CSS_IN_JS_DEPENDENCIES } from './src/utils/styled'; export { assertValidStyle } from './src/utils/assertion'; -export { applicationGenerator } from './src/schematics/application/application'; -export { componentGenerator } from './src/schematics/component/component'; -export { componentCypressGenerator } from './src/schematics/component-cypress-spec/component-cypress-spec'; -export { componentStoryGenerator } from './src/schematics/component-story/component-story'; -export { libraryGenerator } from './src/schematics/library/library'; -export { reduxGenerator } from './src/schematics/redux/redux'; -export { storiesGenerator } from './src/schematics/stories/stories'; -export { storybookConfigurationGenerator } from './src/schematics/storybook-configuration/configuration'; -export { storybookMigration5to6Generator } from './src/schematics/storybook-migrate-defaults-5-to-6/migrate-defaults-5-to-6'; +export { applicationGenerator } from './src/generators/application/application'; +export { componentGenerator } from './src/generators/component/component'; +export { componentCypressGenerator } from './src/generators/component-cypress-spec/component-cypress-spec'; +export { componentStoryGenerator } from './src/generators/component-story/component-story'; +export { libraryGenerator } from './src/generators/library/library'; +export { reduxGenerator } from './src/generators/redux/redux'; +export { storiesGenerator } from './src/generators/stories/stories'; +export { storybookConfigurationGenerator } from './src/generators/storybook-configuration/configuration'; +export { storybookMigration5to6Generator } from './src/generators/storybook-migrate-defaults-5-to-6/migrate-defaults-5-to-6'; diff --git a/packages/react/package.json b/packages/react/package.json index c652e050d5..2221716a01 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -28,14 +28,14 @@ "migrations": "./migrations.json" }, "dependencies": { - "@angular-devkit/core": "~11.0.1", "@babel/core": "7.9.6", "@babel/preset-react": "7.9.4", "@nrwl/cypress": "*", "@nrwl/devkit": "*", "@nrwl/jest": "*", "@nrwl/web": "*", - "@angular-devkit/schematics": "~11.0.1", + "@nrwl/linter": "*", + "@nrwl/storybook": "*", "@svgr/webpack": "^5.4.0", "eslint-plugin-import": "^2.20.1", "eslint-plugin-jsx-a11y": "^6.2.3", diff --git a/packages/react/src/generators/application/application.spec.ts b/packages/react/src/generators/application/application.spec.ts new file mode 100644 index 0000000000..18bb128e09 --- /dev/null +++ b/packages/react/src/generators/application/application.spec.ts @@ -0,0 +1,619 @@ +import * as stripJsonComments from 'strip-json-comments'; +import { + getProjects, + readJson, + readWorkspaceConfiguration, + Tree, +} from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { applicationGenerator } from './application'; +import { Schema } from './schema'; +import { Linter } from '@nrwl/linter'; + +describe('app', () => { + let appTree: Tree; + let schema: Schema = { + babelJest: false, + e2eTestRunner: 'cypress', + skipFormat: false, + unitTestRunner: 'jest', + name: 'myApp', + linter: Linter.EsLint, + style: 'css', + }; + + beforeEach(() => { + appTree = createTreeWithEmptyWorkspace(); + }); + + describe('not nested', () => { + it('should update workspace.json', async () => { + await applicationGenerator(appTree, schema); + + const workspaceJson = readWorkspaceConfiguration(appTree); + const projects = getProjects(appTree); + + expect(projects.get('my-app').root).toEqual('apps/my-app'); + expect(projects.get('my-app-e2e').root).toEqual('apps/my-app-e2e'); + expect(workspaceJson.defaultProject).toEqual('my-app'); + }); + + it('should update nx.json', async () => { + await applicationGenerator(appTree, { ...schema, tags: 'one,two' }); + + const nxJson = readJson(appTree, './nx.json'); + expect(nxJson.projects).toEqual({ + 'my-app': { + tags: ['one', 'two'], + }, + 'my-app-e2e': { + tags: [], + implicitDependencies: ['my-app'], + }, + }); + }); + + it('should generate files', async () => { + await applicationGenerator(appTree, schema); + + expect(appTree.exists('apps/my-app/.babelrc')).toBeTruthy(); + expect(appTree.exists('apps/my-app/.browserslistrc')).toBeTruthy(); + expect(appTree.exists('apps/my-app/src/main.tsx')).toBeTruthy(); + expect(appTree.exists('apps/my-app/src/app/app.tsx')).toBeTruthy(); + expect(appTree.exists('apps/my-app/src/app/app.spec.tsx')).toBeTruthy(); + expect(appTree.exists('apps/my-app/src/app/app.module.css')).toBeTruthy(); + + const jestConfig = appTree.read('apps/my-app/jest.config.js').toString(); + expect(jestConfig).toContain('@nrwl/react/plugins/jest'); + + const tsconfig = readJson(appTree, 'apps/my-app/tsconfig.json'); + expect(tsconfig.references).toEqual([ + { + path: './tsconfig.app.json', + }, + { + path: './tsconfig.spec.json', + }, + ]); + + const tsconfigApp = JSON.parse( + stripJsonComments( + appTree.read('apps/my-app/tsconfig.app.json').toString() + ) + ); + expect(tsconfigApp.compilerOptions.outDir).toEqual('../../dist/out-tsc'); + expect(tsconfigApp.extends).toEqual('./tsconfig.json'); + + const eslintJson = JSON.parse( + stripJsonComments(appTree.read('apps/my-app/.eslintrc.json').toString()) + ); + expect(eslintJson.extends).toEqual([ + 'plugin:@nrwl/nx/react', + '../../.eslintrc.json', + ]); + + expect(appTree.exists('apps/my-app-e2e/cypress.json')).toBeTruthy(); + const tsconfigE2E = JSON.parse( + stripJsonComments( + appTree.read('apps/my-app-e2e/tsconfig.e2e.json').toString() + ) + ); + expect(tsconfigE2E.compilerOptions.outDir).toEqual('../../dist/out-tsc'); + expect(tsconfigE2E.extends).toEqual('./tsconfig.json'); + }); + }); + + describe('nested', () => { + it('should update workspace.json', async () => { + await applicationGenerator(appTree, { ...schema, directory: 'myDir' }); + + const workspaceJson = getProjects(appTree); + + expect(workspaceJson.get('my-dir-my-app').root).toEqual( + 'apps/my-dir/my-app' + ); + expect(workspaceJson.get('my-dir-my-app-e2e').root).toEqual( + 'apps/my-dir/my-app-e2e' + ); + }); + + it('should update nx.json', async () => { + await applicationGenerator(appTree, { + ...schema, + directory: 'myDir', + tags: 'one,two', + }); + + const nxJson = readJson(appTree, '/nx.json'); + expect(nxJson.projects).toEqual({ + 'my-dir-my-app': { + tags: ['one', 'two'], + }, + 'my-dir-my-app-e2e': { + tags: [], + implicitDependencies: ['my-dir-my-app'], + }, + }); + }); + + it('should generate files', async () => { + const hasJsonValue = ({ path, expectedValue, lookupFn }) => { + const content = appTree.read(path).toString(); + const config = JSON.parse(stripJsonComments(content)); + + expect(lookupFn(config)).toEqual(expectedValue); + }; + await applicationGenerator(appTree, { ...schema, directory: 'myDir' }); + + // Make sure these exist + [ + 'apps/my-dir/my-app/src/main.tsx', + 'apps/my-dir/my-app/src/app/app.tsx', + 'apps/my-dir/my-app/src/app/app.spec.tsx', + 'apps/my-dir/my-app/src/app/app.module.css', + ].forEach((path) => { + expect(appTree.exists(path)).toBeTruthy(); + }); + + // Make sure these have properties + [ + { + path: 'apps/my-dir/my-app/tsconfig.app.json', + lookupFn: (json) => json.compilerOptions.outDir, + expectedValue: '../../../dist/out-tsc', + }, + { + path: 'apps/my-dir/my-app-e2e/tsconfig.e2e.json', + lookupFn: (json) => json.compilerOptions.outDir, + expectedValue: '../../../dist/out-tsc', + }, + { + path: 'apps/my-dir/my-app/.eslintrc.json', + lookupFn: (json) => json.extends, + expectedValue: ['plugin:@nrwl/nx/react', '../../../.eslintrc.json'], + }, + ].forEach(hasJsonValue); + }); + }); + + it('should create Nx specific template', async () => { + await applicationGenerator(appTree, { ...schema, directory: 'myDir' }); + + expect( + appTree.read('apps/my-dir/my-app/src/app/app.tsx').toString() + ).toContain('Welcome to my-app'); + }); + + describe('--style scss', () => { + it('should generate scss styles', async () => { + await applicationGenerator(appTree, { ...schema, style: 'scss' }); + expect(appTree.exists('apps/my-app/src/app/app.module.scss')).toEqual( + true + ); + }); + }); + + it('should setup jest with tsx support', async () => { + await applicationGenerator(appTree, { ...schema, name: 'my-app' }); + + expect(appTree.read('apps/my-app/jest.config.js').toString()).toContain( + `moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],` + ); + }); + + it('should setup jest without serializers', async () => { + await applicationGenerator(appTree, { ...schema, name: 'my-app' }); + + expect(appTree.read('apps/my-app/jest.config.js').toString()).not.toContain( + `'jest-preset-angular/build/AngularSnapshotSerializer.js',` + ); + }); + + it('should setup the nrwl web build builder', async () => { + await applicationGenerator(appTree, { ...schema, name: 'my-app' }); + + const workspaceJson = getProjects(appTree); + const targetConfig = workspaceJson.get('my-app').targets; + expect(targetConfig.build.executor).toEqual('@nrwl/web:build'); + expect(targetConfig.build.outputs).toEqual(['{options.outputPath}']); + expect(targetConfig.build.options).toEqual({ + assets: ['apps/my-app/src/favicon.ico', 'apps/my-app/src/assets'], + index: 'apps/my-app/src/index.html', + main: 'apps/my-app/src/main.tsx', + outputPath: 'dist/apps/my-app', + polyfills: 'apps/my-app/src/polyfills.ts', + scripts: [], + styles: ['apps/my-app/src/styles.css'], + tsConfig: 'apps/my-app/tsconfig.app.json', + webpackConfig: '@nrwl/react/plugins/webpack', + }); + expect(targetConfig.build.configurations.production).toEqual({ + optimization: true, + budgets: [ + { + maximumError: '5mb', + maximumWarning: '2mb', + type: 'initial', + }, + ], + extractCss: true, + extractLicenses: true, + fileReplacements: [ + { + replace: 'apps/my-app/src/environments/environment.ts', + with: 'apps/my-app/src/environments/environment.prod.ts', + }, + ], + namedChunks: false, + outputHashing: 'all', + sourceMap: false, + vendorChunk: false, + }); + }); + + it('should setup the nrwl web dev server builder', async () => { + await applicationGenerator(appTree, { ...schema, name: 'my-app' }); + + const workspaceJson = getProjects(appTree); + const targetConfig = workspaceJson.get('my-app').targets; + expect(targetConfig.serve.executor).toEqual('@nrwl/web:dev-server'); + expect(targetConfig.serve.options).toEqual({ + buildTarget: 'my-app:build', + }); + expect(targetConfig.serve.configurations.production).toEqual({ + buildTarget: 'my-app:build:production', + }); + }); + + it('should setup the eslint builder', async () => { + await applicationGenerator(appTree, { ...schema, name: 'my-app' }); + + const workspaceJson = getProjects(appTree); + expect(workspaceJson.get('my-app').targets.lint).toEqual({ + executor: '@nrwl/linter:eslint', + options: { + lintFilePatterns: ['apps/my-app/**/*.{ts,tsx,js,jsx}'], + }, + }); + }); + + describe('--unit-test-runner none', () => { + it('should not generate test configuration', async () => { + await applicationGenerator(appTree, { + ...schema, + unitTestRunner: 'none', + }); + + expect(appTree.exists('jest.config.js')).toBeFalsy(); + expect(appTree.exists('apps/my-app/src/app/app.spec.tsx')).toBeFalsy(); + expect(appTree.exists('apps/my-app/tsconfig.spec.json')).toBeFalsy(); + expect(appTree.exists('apps/my-app/jest.config.js')).toBeFalsy(); + const workspaceJson = getProjects(appTree); + expect(workspaceJson.get('my-app').targets.test).toBeUndefined(); + expect(workspaceJson.get('my-app').targets.lint).toMatchInlineSnapshot(` + Object { + "executor": "@nrwl/linter:eslint", + "options": Object { + "lintFilePatterns": Array [ + "apps/my-app/**/*.{ts,tsx,js,jsx}", + ], + }, + } + `); + }); + }); + + describe('--e2e-test-runner none', () => { + it('should not generate test configuration', async () => { + await applicationGenerator(appTree, { ...schema, e2eTestRunner: 'none' }); + + expect(appTree.exists('apps/my-app-e2e')).toBeFalsy(); + const workspaceJson = getProjects(appTree); + expect(workspaceJson.get('my-app-e2e')).toBeUndefined(); + }); + }); + + describe('--pascalCaseFiles', () => { + it('should use upper case app file', async () => { + await applicationGenerator(appTree, { ...schema, pascalCaseFiles: true }); + + expect(appTree.exists('apps/my-app/src/app/App.tsx')).toBeTruthy(); + expect(appTree.exists('apps/my-app/src/app/App.spec.tsx')).toBeTruthy(); + expect(appTree.exists('apps/my-app/src/app/App.module.css')).toBeTruthy(); + }); + }); + + it('should generate functional components by default', async () => { + await applicationGenerator(appTree, schema); + + const appContent = appTree.read('apps/my-app/src/app/app.tsx').toString(); + + expect(appContent).not.toMatch(/extends Component/); + }); + + it('should add .eslintrc.json and dependencies', async () => { + await applicationGenerator(appTree, { ...schema, linter: Linter.EsLint }); + + const eslintJson = readJson(appTree, '/apps/my-app/.eslintrc.json'); + const packageJson = readJson(appTree, '/package.json'); + + expect(eslintJson.extends).toEqual( + expect.arrayContaining(['plugin:@nrwl/nx/react']) + ); + expect(packageJson.devDependencies.eslint).toBeDefined(); + expect(packageJson.devDependencies['@nrwl/linter']).toBeDefined(); + expect(packageJson.devDependencies['@nrwl/eslint-plugin-nx']).toBeDefined(); + expect(packageJson.devDependencies['eslint-plugin-react']).toBeDefined(); + expect( + packageJson.devDependencies['eslint-plugin-react-hooks'] + ).toBeDefined(); + expect( + packageJson.devDependencies['@typescript-eslint/parser'] + ).toBeDefined(); + expect( + packageJson.devDependencies['@typescript-eslint/eslint-plugin'] + ).toBeDefined(); + expect(packageJson.devDependencies['eslint-config-prettier']).toBeDefined(); + }); + + describe('--class-component', () => { + it('should generate class components', async () => { + await applicationGenerator(appTree, { ...schema, classComponent: true }); + + const appContent = appTree.read('apps/my-app/src/app/app.tsx').toString(); + + expect(appContent).toMatch(/extends Component/); + }); + }); + + describe('--style none', () => { + it('should not generate any styles', async () => { + await applicationGenerator(appTree, { ...schema, style: 'none' }); + + expect(appTree.exists('apps/my-app/src/app/app.tsx')).toBeTruthy(); + expect(appTree.exists('apps/my-app/src/app/app.spec.tsx')).toBeTruthy(); + expect(appTree.exists('apps/my-app/src/app/app.css')).toBeFalsy(); + expect(appTree.exists('apps/my-app/src/app/app.scss')).toBeFalsy(); + expect(appTree.exists('apps/my-app/src/app/app.styl')).toBeFalsy(); + expect(appTree.exists('apps/my-app/src/app/app.module.css')).toBeFalsy(); + expect(appTree.exists('apps/my-app/src/app/app.module.scss')).toBeFalsy(); + expect(appTree.exists('apps/my-app/src/app/app.module.styl')).toBeFalsy(); + + const content = appTree.read('apps/my-app/src/app/app.tsx').toString(); + expect(content).not.toContain('styled-components'); + expect(content).not.toContain(''); + expect(content).not.toContain('@emotion/styled'); + expect(content).not.toContain(''); + + //for imports + expect(content).not.toContain('app.styl'); + expect(content).not.toContain('app.css'); + expect(content).not.toContain('app.scss'); + expect(content).not.toContain('app.module.styl'); + expect(content).not.toContain('app.module.css'); + expect(content).not.toContain('app.module.scss'); + }); + + it('should set defaults when style: none', async () => { + await applicationGenerator(appTree, { ...schema, style: 'none' }); + + const workspaceJson = readWorkspaceConfiguration(appTree); + expect(workspaceJson.generators['@nrwl/react']).toMatchObject({ + application: { + style: 'none', + }, + component: { + style: 'none', + }, + library: { + style: 'none', + }, + }); + }); + + it('should exclude styles from workspace.json', async () => { + await applicationGenerator(appTree, { ...schema, style: 'none' }); + + const workspaceJson = getProjects(appTree); + + expect(workspaceJson.get('my-app').targets.build.options.styles).toEqual( + [] + ); + }); + }); + + describe('--style styled-components', () => { + it('should use styled-components as the styled API library', async () => { + await applicationGenerator(appTree, { + ...schema, + style: 'styled-components', + }); + + expect( + appTree.exists('apps/my-app/src/app/app.styled-components') + ).toBeFalsy(); + expect(appTree.exists('apps/my-app/src/app/app.tsx')).toBeTruthy(); + expect( + appTree.exists('apps/my-app/src/styles.styled-components') + ).toBeFalsy(); + + const content = appTree.read('apps/my-app/src/app/app.tsx').toString(); + expect(content).toContain('styled-component'); + expect(content).toContain(''); + }); + + it('should add dependencies to package.json', async () => { + await applicationGenerator(appTree, { + ...schema, + style: 'styled-components', + }); + + const packageJSON = readJson(appTree, 'package.json'); + expect(packageJSON.dependencies['styled-components']).toBeDefined(); + }); + }); + + describe('--style @emotion/styled', () => { + it('should use @emotion/styled as the styled API library', async () => { + await applicationGenerator(appTree, { + ...schema, + style: '@emotion/styled', + }); + + expect( + appTree.exists('apps/my-app/src/app/app.@emotion/styled') + ).toBeFalsy(); + expect(appTree.exists('apps/my-app/src/app/app.tsx')).toBeTruthy(); + + const content = appTree.read('apps/my-app/src/app/app.tsx').toString(); + expect(content).toContain('@emotion/styled'); + expect(content).toContain(''); + }); + + it('should exclude styles from workspace.json', async () => { + await applicationGenerator(appTree, { + ...schema, + style: '@emotion/styled', + }); + + const workspaceJson = getProjects(appTree); + + expect(workspaceJson.get('my-app').targets.build.options.styles).toEqual( + [] + ); + }); + + it('should add dependencies to package.json', async () => { + await applicationGenerator(appTree, { + ...schema, + style: '@emotion/styled', + }); + + const packageJSON = readJson(appTree, 'package.json'); + expect(packageJSON.dependencies['@emotion/react']).toBeDefined(); + expect(packageJSON.dependencies['@emotion/styled']).toBeDefined(); + }); + }); + + describe('--style styled-jsx', () => { + it('should use styled-jsx as the styled API library', async () => { + await applicationGenerator(appTree, { + ...schema, + style: 'styled-jsx', + }); + + expect(appTree.exists('apps/my-app/src/app/app.styled-jsx')).toBeFalsy(); + expect(appTree.exists('apps/my-app/src/app/app.tsx')).toBeTruthy(); + + const content = appTree.read('apps/my-app/src/app/app.tsx').toString(); + expect(content).toContain('` : `` %> + <% if (styledModule === 'styled-jsx') { %><% } %>

Welcome to <%= name %>!

<% if (routing) { %>
    diff --git a/packages/react/src/schematics/component/schema.d.ts b/packages/react/src/generators/component/schema.d.ts similarity index 74% rename from packages/react/src/schematics/component/schema.d.ts rename to packages/react/src/generators/component/schema.d.ts index 02e9d4811a..c571eb8f2b 100644 --- a/packages/react/src/schematics/component/schema.d.ts +++ b/packages/react/src/generators/component/schema.d.ts @@ -1,9 +1,9 @@ -import { SupportedStyles } from 'packages/react/typings/style'; +import { SupportedStyles } from '../../../typings/style'; export interface Schema { name: string; project: string; - style?: SupportedStyles; + style: SupportedStyles; skipTests?: boolean; directory?: string; export?: boolean; diff --git a/packages/react/src/schematics/component/schema.json b/packages/react/src/generators/component/schema.json similarity index 100% rename from packages/react/src/schematics/component/schema.json rename to packages/react/src/generators/component/schema.json diff --git a/packages/react/src/generators/init/init.spec.ts b/packages/react/src/generators/init/init.spec.ts new file mode 100644 index 0000000000..f227040e1a --- /dev/null +++ b/packages/react/src/generators/init/init.spec.ts @@ -0,0 +1,42 @@ +import { readJson, readWorkspaceConfiguration, Tree } from '@nrwl/devkit'; +import { reactVersion } from '../../utils/versions'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import reactInitGenerator from './init'; +import { InitSchema } from './schema'; + +describe('init', () => { + let tree: Tree; + let schema: InitSchema = { + unitTestRunner: 'jest', + e2eTestRunner: 'cypress', + skipFormat: false, + }; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + it('should add react dependencies', async () => { + await reactInitGenerator(tree, schema); + const packageJson = readJson(tree, 'package.json'); + expect(packageJson.dependencies['react']).toBeDefined(); + expect(packageJson.dependencies['react-dom']).toBeDefined(); + expect(packageJson.devDependencies['@types/react']).toBeDefined(); + expect(packageJson.devDependencies['@types/react-dom']).toBeDefined(); + expect(packageJson.devDependencies['@testing-library/react']).toBeDefined(); + }); + + describe('defaultCollection', () => { + it('should be set if none was set before', async () => { + await reactInitGenerator(tree, schema); + const workspace = readWorkspaceConfiguration(tree); + expect(workspace.cli.defaultCollection).toEqual('@nrwl/react'); + expect(workspace.generators['@nrwl/react'].application.babel).toBe(true); + }); + }); + + it('should not add jest config if unitTestRunner is none', async () => { + await reactInitGenerator(tree, { ...schema, unitTestRunner: 'none' }); + expect(tree.exists('jest.config.js')).toEqual(false); + }); +}); diff --git a/packages/react/src/generators/init/init.ts b/packages/react/src/generators/init/init.ts new file mode 100644 index 0000000000..01bff4e55e --- /dev/null +++ b/packages/react/src/generators/init/init.ts @@ -0,0 +1,77 @@ +import { InitSchema } from './schema'; +import { + addDependenciesToPackageJson, + convertNxGenerator, + GeneratorCallback, + readWorkspaceConfiguration, + setDefaultCollection, + Tree, + updateWorkspaceConfiguration, +} from '@nrwl/devkit'; +import { jestInitGenerator } from '@nrwl/jest'; +import { cypressInitGenerator } from '@nrwl/cypress'; +import { webInitGenerator } from '@nrwl/web'; +import { + nxVersion, + reactDomVersion, + reactVersion, + testingLibraryReactVersion, + typesReactDomVersion, + typesReactVersion, +} from '../../utils/versions'; + +function setDefault(host: Tree) { + const workspace = readWorkspaceConfiguration(host); + + workspace.generators = workspace.generators || {}; + const reactGenerators = workspace.generators['@nrwl/react'] || {}; + const generators = { + ...workspace.generators, + '@nrwl/react': { + ...reactGenerators, + application: { + ...reactGenerators.application, + babel: true, + }, + }, + }; + + updateWorkspaceConfiguration(host, { ...workspace, generators }); + setDefaultCollection(host, '@nrwl/react'); +} + +export async function reactInitGenerator(host: Tree, schema: InitSchema) { + let installTask: GeneratorCallback; + + setDefault(host); + + if (!schema.unitTestRunner || schema.unitTestRunner === 'jest') { + installTask = jestInitGenerator(host, {}); + } + if (!schema.e2eTestRunner || schema.e2eTestRunner === 'cypress') { + installTask = cypressInitGenerator(host) || installTask; + } + + await webInitGenerator(host, schema); + installTask = addDependenciesToPackageJson( + host, + { + 'core-js': '^3.6.5', + react: reactVersion, + 'react-dom': reactDomVersion, + tslib: '^2.0.0', + }, + { + '@nrwl/react': nxVersion, + '@types/react': typesReactVersion, + '@types/react-dom': typesReactDomVersion, + '@testing-library/react': testingLibraryReactVersion, + } + ); + + return installTask; +} + +export default reactInitGenerator; + +export const reactInitSchematic = convertNxGenerator(reactInitGenerator); diff --git a/packages/react/src/schematics/init/schema.d.ts b/packages/react/src/generators/init/schema.d.ts similarity index 76% rename from packages/react/src/schematics/init/schema.d.ts rename to packages/react/src/generators/init/schema.d.ts index 9f7995c018..3501da6ae0 100644 --- a/packages/react/src/schematics/init/schema.d.ts +++ b/packages/react/src/generators/init/schema.d.ts @@ -1,4 +1,4 @@ -export interface Schema { +export interface InitSchema { unitTestRunner: 'jest' | 'none'; e2eTestRunner: 'cypress' | 'none'; skipFormat: boolean; diff --git a/packages/react/src/schematics/init/schema.json b/packages/react/src/generators/init/schema.json similarity index 97% rename from packages/react/src/schematics/init/schema.json rename to packages/react/src/generators/init/schema.json index 33c317b9a6..a22cd92269 100644 --- a/packages/react/src/schematics/init/schema.json +++ b/packages/react/src/generators/init/schema.json @@ -2,6 +2,7 @@ "$schema": "http://json-schema.org/schema", "id": "NxReactNgInit", "title": "Init React Plugin", + "cli": "nx", "type": "object", "properties": { "unitTestRunner": { diff --git a/packages/react/src/schematics/library/files/lib/.babelrc__tmpl__ b/packages/react/src/generators/library/files/lib/.babelrc__tmpl__ similarity index 66% rename from packages/react/src/schematics/library/files/lib/.babelrc__tmpl__ rename to packages/react/src/generators/library/files/lib/.babelrc__tmpl__ index c851ab175f..e6ae8f7dba 100644 --- a/packages/react/src/schematics/library/files/lib/.babelrc__tmpl__ +++ b/packages/react/src/generators/library/files/lib/.babelrc__tmpl__ @@ -1,7 +1,7 @@ { "presets": [ - "@nrwl/react/babel", - <% if (style === '@emotion/styled') { %>"@emotion/babel-preset-css-prop"<% } %> + "@nrwl/react/babel" + <% if (style === '@emotion/styled') { %>,"@emotion/babel-preset-css-prop"<% } %> ], "plugins": [ <% if (style === 'styled-components') { %>["styled-components", { "pure": true, "ssr": true }]<% } %> diff --git a/packages/react/src/schematics/library/files/lib/README.md b/packages/react/src/generators/library/files/lib/README.md similarity index 100% rename from packages/react/src/schematics/library/files/lib/README.md rename to packages/react/src/generators/library/files/lib/README.md diff --git a/packages/react/src/schematics/library/files/lib/package.json__tmpl__ b/packages/react/src/generators/library/files/lib/package.json__tmpl__ similarity index 100% rename from packages/react/src/schematics/library/files/lib/package.json__tmpl__ rename to packages/react/src/generators/library/files/lib/package.json__tmpl__ diff --git a/packages/react/src/schematics/library/files/lib/src/index.ts__tmpl__ b/packages/react/src/generators/library/files/lib/src/index.ts__tmpl__ similarity index 100% rename from packages/react/src/schematics/library/files/lib/src/index.ts__tmpl__ rename to packages/react/src/generators/library/files/lib/src/index.ts__tmpl__ diff --git a/packages/react/src/schematics/library/files/lib/tsconfig.json__tmpl__ b/packages/react/src/generators/library/files/lib/tsconfig.json__tmpl__ similarity index 100% rename from packages/react/src/schematics/library/files/lib/tsconfig.json__tmpl__ rename to packages/react/src/generators/library/files/lib/tsconfig.json__tmpl__ diff --git a/packages/react/src/schematics/library/files/lib/tsconfig.lib.json b/packages/react/src/generators/library/files/lib/tsconfig.lib.json__tmpl__ similarity index 100% rename from packages/react/src/schematics/library/files/lib/tsconfig.lib.json rename to packages/react/src/generators/library/files/lib/tsconfig.lib.json__tmpl__ diff --git a/packages/react/src/generators/library/library.spec.ts b/packages/react/src/generators/library/library.spec.ts new file mode 100644 index 0000000000..dcf7c2deba --- /dev/null +++ b/packages/react/src/generators/library/library.spec.ts @@ -0,0 +1,543 @@ +import { getProjects, readJson, Tree, updateJson } from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import libraryGenerator from './library'; +import { Linter } from '@nrwl/linter'; +import { Schema } from './schema'; +import applicationGenerator from '../application/application'; + +describe('lib', () => { + let appTree: Tree; + + let defaultSchema: Schema = { + name: 'myLib', + linter: Linter.EsLint, + skipFormat: false, + skipTsConfig: false, + unitTestRunner: 'jest', + style: 'css', + component: true, + }; + + beforeEach(() => { + appTree = createTreeWithEmptyWorkspace(); + }); + + describe('not nested', () => { + it('should update workspace.json', async () => { + await libraryGenerator(appTree, defaultSchema); + const workspaceJson = readJson(appTree, '/workspace.json'); + expect(workspaceJson.projects['my-lib'].root).toEqual('libs/my-lib'); + expect(workspaceJson.projects['my-lib'].architect.build).toBeUndefined(); + expect(workspaceJson.projects['my-lib'].architect.lint).toEqual({ + builder: '@nrwl/linter:eslint', + options: { + lintFilePatterns: ['libs/my-lib/**/*.{ts,tsx,js,jsx}'], + }, + }); + }); + + it('should update nx.json', async () => { + await libraryGenerator(appTree, { ...defaultSchema, tags: 'one,two' }); + const nxJson = readJson(appTree, '/nx.json'); + expect(nxJson.projects).toEqual({ + 'my-lib': { + tags: ['one', 'two'], + }, + }); + }); + + it('should add react and react-dom packages to package.json if not already present', async () => { + await libraryGenerator(appTree, defaultSchema); + + const packageJson = readJson(appTree, '/package.json'); + + expect(packageJson).toMatchObject({ + dependencies: { + react: expect.anything(), + 'react-dom': expect.anything(), + }, + }); + }); + + it('should update tsconfig.base.json', async () => { + await libraryGenerator(appTree, defaultSchema); + const tsconfigJson = readJson(appTree, '/tsconfig.base.json'); + expect(tsconfigJson.compilerOptions.paths['@proj/my-lib']).toEqual([ + 'libs/my-lib/src/index.ts', + ]); + }); + + it('should update root tsconfig.base.json (no existing path mappings)', async () => { + updateJson(appTree, 'tsconfig.base.json', (json) => { + json.compilerOptions.paths = undefined; + return json; + }); + + await libraryGenerator(appTree, defaultSchema); + const tsconfigJson = readJson(appTree, '/tsconfig.base.json'); + expect(tsconfigJson.compilerOptions.paths['@proj/my-lib']).toEqual([ + 'libs/my-lib/src/index.ts', + ]); + }); + + it('should create a local tsconfig.json', async () => { + await libraryGenerator(appTree, defaultSchema); + const tsconfigJson = readJson(appTree, 'libs/my-lib/tsconfig.json'); + expect(tsconfigJson.references).toEqual([ + { + path: './tsconfig.lib.json', + }, + { + path: './tsconfig.spec.json', + }, + ]); + }); + + it('should extend the local tsconfig.json with tsconfig.spec.json', async () => { + await libraryGenerator(appTree, defaultSchema); + const tsconfigJson = readJson(appTree, 'libs/my-lib/tsconfig.spec.json'); + expect(tsconfigJson.extends).toEqual('./tsconfig.json'); + }); + + it('should extend the local tsconfig.json with tsconfig.lib.json', async () => { + await libraryGenerator(appTree, defaultSchema); + const tsconfigJson = readJson(appTree, 'libs/my-lib/tsconfig.lib.json'); + expect(tsconfigJson.extends).toEqual('./tsconfig.json'); + }); + + it('should generate files', async () => { + await libraryGenerator(appTree, defaultSchema); + expect(appTree.exists('libs/my-lib/package.json')).toBeFalsy(); + expect(appTree.exists(`libs/my-lib/jest.config.js`)).toBeTruthy(); + expect(appTree.exists('libs/my-lib/src/index.ts')).toBeTruthy(); + expect(appTree.exists('libs/my-lib/src/lib/my-lib.tsx')).toBeTruthy(); + expect( + appTree.exists('libs/my-lib/src/lib/my-lib.module.css') + ).toBeTruthy(); + expect( + appTree.exists('libs/my-lib/src/lib/my-lib.spec.tsx') + ).toBeTruthy(); + }); + }); + + describe('nested', () => { + it('should update nx.json', async () => { + await libraryGenerator(appTree, { + ...defaultSchema, + directory: 'myDir', + tags: 'one', + }); + const nxJson = readJson(appTree, '/nx.json'); + expect(nxJson.projects).toEqual({ + 'my-dir-my-lib': { + tags: ['one'], + }, + }); + + await libraryGenerator(appTree, { + ...defaultSchema, + name: 'myLib2', + directory: 'myDir', + tags: 'one,two', + }); + + const nxJson2 = readJson(appTree, '/nx.json'); + expect(nxJson2.projects).toEqual({ + 'my-dir-my-lib': { + tags: ['one'], + }, + 'my-dir-my-lib2': { + tags: ['one', 'two'], + }, + }); + }); + + it('should generate files', async () => { + await libraryGenerator(appTree, { ...defaultSchema, directory: 'myDir' }); + expect(appTree.exists(`libs/my-dir/my-lib/jest.config.js`)).toBeTruthy(); + expect(appTree.exists('libs/my-dir/my-lib/src/index.ts')).toBeTruthy(); + expect( + appTree.exists('libs/my-dir/my-lib/src/lib/my-dir-my-lib.tsx') + ).toBeTruthy(); + expect( + appTree.exists('libs/my-dir/my-lib/src/lib/my-dir-my-lib.module.css') + ).toBeTruthy(); + expect( + appTree.exists('libs/my-dir/my-lib/src/lib/my-dir-my-lib.spec.tsx') + ).toBeTruthy(); + }); + + it('should update workspace.json', async () => { + await libraryGenerator(appTree, { ...defaultSchema, directory: 'myDir' }); + const workspaceJson = readJson(appTree, '/workspace.json'); + + expect(workspaceJson.projects['my-dir-my-lib'].root).toEqual( + 'libs/my-dir/my-lib' + ); + expect(workspaceJson.projects['my-dir-my-lib'].architect.lint).toEqual({ + builder: '@nrwl/linter:eslint', + options: { + lintFilePatterns: ['libs/my-dir/my-lib/**/*.{ts,tsx,js,jsx}'], + }, + }); + }); + + it('should update tsconfig.base.json', async () => { + await libraryGenerator(appTree, { ...defaultSchema, directory: 'myDir' }); + const tsconfigJson = readJson(appTree, '/tsconfig.base.json'); + expect( + tsconfigJson.compilerOptions.paths['@proj/my-dir/my-lib'] + ).toEqual(['libs/my-dir/my-lib/src/index.ts']); + expect( + tsconfigJson.compilerOptions.paths['my-dir-my-lib/*'] + ).toBeUndefined(); + }); + + it('should create a local tsconfig.json', async () => { + await libraryGenerator(appTree, { ...defaultSchema, directory: 'myDir' }); + + const tsconfigJson = readJson( + appTree, + 'libs/my-dir/my-lib/tsconfig.json' + ); + expect(tsconfigJson.references).toEqual([ + { + path: './tsconfig.lib.json', + }, + { + path: './tsconfig.spec.json', + }, + ]); + }); + }); + + describe('--style scss', () => { + it('should use scss for styles', async () => { + await libraryGenerator(appTree, { ...defaultSchema, style: 'scss' }); + + expect( + appTree.exists('libs/my-lib/src/lib/my-lib.module.scss') + ).toBeTruthy(); + }); + }); + + describe('--style none', () => { + it('should not use styles when style none', async () => { + await libraryGenerator(appTree, { ...defaultSchema, style: 'none' }); + + expect(appTree.exists('libs/my-lib/src/lib/my-lib.tsx')).toBeTruthy(); + expect( + appTree.exists('libs/my-lib/src/lib/my-lib.spec.tsx') + ).toBeTruthy(); + expect(appTree.exists('libs/my-lib/src/lib/my-lib.css')).toBeFalsy(); + expect(appTree.exists('libs/my-lib/src/lib/my-lib.scss')).toBeFalsy(); + expect(appTree.exists('libs/my-lib/src/lib/my-lib.styl')).toBeFalsy(); + expect( + appTree.exists('libs/my-lib/src/lib/my-lib.module.css') + ).toBeFalsy(); + expect( + appTree.exists('libs/my-lib/src/lib/my-lib.module.scss') + ).toBeFalsy(); + expect( + appTree.exists('libs/my-lib/src/lib/my-lib.module.styl') + ).toBeFalsy(); + + const content = appTree.read('libs/my-lib/src/lib/my-lib.tsx').toString(); + expect(content).not.toContain('styled-components'); + expect(content).not.toContain(''); + expect(content).not.toContain('@emotion/styled'); + expect(content).not.toContain(''); + + //for imports + expect(content).not.toContain('app.styl'); + expect(content).not.toContain('app.css'); + expect(content).not.toContain('app.scss'); + expect(content).not.toContain('app.module.styl'); + expect(content).not.toContain('app.module.css'); + expect(content).not.toContain('app.module.scss'); + }); + }); + + describe('--no-component', () => { + it('should not generate components or styles', async () => { + await libraryGenerator(appTree, { ...defaultSchema, component: false }); + + expect(appTree.exists('libs/my-lib/src/lib')).toBeFalsy(); + }); + }); + + describe('--unit-test-runner none', () => { + it('should not generate test configuration', async () => { + await libraryGenerator(appTree, { + ...defaultSchema, + unitTestRunner: 'none', + }); + + expect(appTree.exists('libs/my-lib/tsconfig.spec.json')).toBeFalsy(); + expect(appTree.exists('libs/my-lib/jest.config.js')).toBeFalsy(); + const workspaceJson = readJson(appTree, 'workspace.json'); + expect(workspaceJson.projects['my-lib'].architect.test).toBeUndefined(); + expect(workspaceJson.projects['my-lib'].architect.lint) + .toMatchInlineSnapshot(` + Object { + "builder": "@nrwl/linter:eslint", + "options": Object { + "lintFilePatterns": Array [ + "libs/my-lib/**/*.{ts,tsx,js,jsx}", + ], + }, + } + `); + }); + }); + + describe('--appProject', () => { + it('should add new route to existing routing code', async () => { + await applicationGenerator(appTree, { + babelJest: true, + e2eTestRunner: 'none', + linter: Linter.EsLint, + skipFormat: true, + unitTestRunner: 'jest', + name: 'myApp', + routing: true, + style: 'css', + }); + + await libraryGenerator(appTree, { + ...defaultSchema, + appProject: 'my-app', + }); + + const appSource = appTree.read('apps/my-app/src/app/app.tsx').toString(); + const mainSource = appTree.read('apps/my-app/src/main.tsx').toString(); + + expect(mainSource).toContain('react-router-dom'); + expect(mainSource).toContain(''); + expect(appSource).toContain('@proj/my-lib'); + expect(appSource).toContain('react-router-dom'); + expect(appSource).toMatch(/ { + await applicationGenerator(appTree, { + babelJest: true, + e2eTestRunner: 'none', + linter: Linter.EsLint, + skipFormat: true, + unitTestRunner: 'jest', + name: 'myApp', + style: 'css', + }); + + await libraryGenerator(appTree, { + ...defaultSchema, + appProject: 'my-app', + }); + + const appSource = appTree.read('apps/my-app/src/app/app.tsx').toString(); + const mainSource = appTree.read('apps/my-app/src/main.tsx').toString(); + + expect(mainSource).toContain('react-router-dom'); + expect(mainSource).toContain(''); + expect(appSource).toContain('@proj/my-lib'); + expect(appSource).toContain('react-router-dom'); + expect(appSource).toMatch(/ { + it('should have a builder defined', async () => { + await libraryGenerator(appTree, { + ...defaultSchema, + buildable: true, + }); + + const workspaceJson = getProjects(appTree); + + expect(workspaceJson.get('my-lib').targets.build).toBeDefined(); + }); + }); + + describe('--publishable', () => { + it('should add build architect', async () => { + await libraryGenerator(appTree, { + ...defaultSchema, + publishable: true, + importPath: '@proj/my-lib', + }); + + const workspaceJson = getProjects(appTree); + + expect(workspaceJson.get('my-lib').targets.build).toMatchObject({ + executor: '@nrwl/web:package', + outputs: ['{options.outputPath}'], + options: { + external: ['react', 'react-dom'], + entryFile: 'libs/my-lib/src/index.ts', + outputPath: 'dist/libs/my-lib', + project: 'libs/my-lib/package.json', + tsConfig: 'libs/my-lib/tsconfig.lib.json', + babelConfig: '@nrwl/react/plugins/bundle-babel', + rollupConfig: '@nrwl/react/plugins/bundle-rollup', + }, + }); + }); + + it('should fail if no importPath is provided with publishable', async () => { + expect.assertions(1); + + try { + await libraryGenerator(appTree, { + ...defaultSchema, + directory: 'myDir', + publishable: true, + }); + } catch (e) { + expect(e.message).toContain( + 'For publishable libs you have to provide a proper "--importPath" which needs to be a valid npm package name (e.g. my-awesome-lib or @myorg/my-lib)' + ); + } + }); + + it('should support styled-components', async () => { + await libraryGenerator(appTree, { + ...defaultSchema, + publishable: true, + importPath: '@proj/my-lib', + style: 'styled-components', + }); + + const workspaceJson = readJson(appTree, '/workspace.json'); + + expect(workspaceJson.projects['my-lib'].architect.build).toMatchObject({ + options: { + external: ['react', 'react-dom', 'react-is', 'styled-components'], + }, + }); + }); + + it('should support @emotion/styled', async () => { + await libraryGenerator(appTree, { + ...defaultSchema, + publishable: true, + importPath: '@proj/my-lib', + style: '@emotion/styled', + }); + + const workspaceJson = readJson(appTree, '/workspace.json'); + + expect(workspaceJson.projects['my-lib'].architect.build).toMatchObject({ + options: { + external: ['react', 'react-dom', '@emotion/styled', '@emotion/react'], + }, + }); + }); + + it('should support styled-jsx', async () => { + await libraryGenerator(appTree, { + ...defaultSchema, + publishable: true, + importPath: '@proj/my-lib', + style: 'styled-jsx', + }); + + const workspaceJson = readJson(appTree, '/workspace.json'); + const babelrc = readJson(appTree, 'libs/my-lib/.babelrc'); + const babelJestConfig = readJson( + appTree, + 'libs/my-lib/babel-jest.config.json' + ); + + expect(workspaceJson.projects['my-lib'].architect.build).toMatchObject({ + options: { + external: ['react', 'react-dom', 'styled-jsx'], + }, + }); + expect(babelrc.plugins).toContain('styled-jsx/babel'); + expect(babelJestConfig.plugins).toContain('styled-jsx/babel'); + }); + + it('should support style none', async () => { + await libraryGenerator(appTree, { + ...defaultSchema, + publishable: true, + importPath: '@proj/my-lib', + style: 'none', + }); + + const workspaceJson = readJson(appTree, '/workspace.json'); + + expect(workspaceJson.projects['my-lib'].architect.build).toMatchObject({ + options: { + external: ['react', 'react-dom'], + }, + }); + }); + + it('should add package.json and .babelrc', async () => { + await libraryGenerator(appTree, { + ...defaultSchema, + publishable: true, + importPath: '@proj/my-lib', + }); + + const packageJson = readJson(appTree, '/libs/my-lib/package.json'); + expect(packageJson.name).toEqual('@proj/my-lib'); + expect(appTree.exists('/libs/my-lib/.babelrc')); + }); + }); + + describe('--js', () => { + it('should generate JS files', async () => { + await libraryGenerator(appTree, { + ...defaultSchema, + js: true, + }); + + expect(appTree.exists('/libs/my-lib/src/index.js')).toBe(true); + }); + }); + + describe('--importPath', () => { + it('should update the package.json & tsconfig with the given import path', async () => { + await libraryGenerator(appTree, { + ...defaultSchema, + publishable: true, + directory: 'myDir', + importPath: '@myorg/lib', + }); + const packageJson = readJson(appTree, 'libs/my-dir/my-lib/package.json'); + const tsconfigJson = readJson(appTree, '/tsconfig.base.json'); + + expect(packageJson.name).toBe('@myorg/lib'); + expect( + tsconfigJson.compilerOptions.paths[packageJson.name] + ).toBeDefined(); + }); + + it('should fail if the same importPath has already been used', async () => { + await libraryGenerator(appTree, { + ...defaultSchema, + name: 'myLib1', + publishable: true, + importPath: '@myorg/lib', + }); + + try { + await libraryGenerator(appTree, { + ...defaultSchema, + name: 'myLib2', + publishable: true, + importPath: '@myorg/lib', + }); + } catch (e) { + expect(e.message).toContain( + 'You already have a library using the import path' + ); + } + + expect.assertions(1); + }); + }); +}); diff --git a/packages/react/src/generators/library/library.ts b/packages/react/src/generators/library/library.ts new file mode 100644 index 0000000000..83ba2f4700 --- /dev/null +++ b/packages/react/src/generators/library/library.ts @@ -0,0 +1,418 @@ +import { CSS_IN_JS_DEPENDENCIES } from '../../utils/styled'; + +import * as ts from 'typescript'; +import { assertValidStyle } from '../../utils/assertion'; +import { + addBrowserRouter, + addInitialRoutes, + addRoute, + findComponentImportPath, +} from '../../utils/ast-utils'; +import { extraEslintDependencies, reactEslintJson } from '../../utils/lint'; +import { + reactDomVersion, + reactRouterDomVersion, + reactVersion, + typesReactRouterDomVersion, +} from '../../utils/versions'; +import { Schema } from './schema'; +import { updateBabelJestConfig } from '../../rules/update-babel-jest-config'; +import { + addDependenciesToPackageJson, + addProjectConfiguration, + applyChangesToString, + convertNxGenerator, + formatFiles, + generateFiles, + GeneratorCallback, + getProjects, + getWorkspaceLayout, + joinPathFragments, + names, + normalizePath, + offsetFromRoot, + toJS, + Tree, + updateJson, +} from '@nrwl/devkit'; +import init from '../init/init'; +import { Linter, lintProjectGenerator } from '@nrwl/linter'; +import { jestProjectGenerator } from '@nrwl/jest'; +import componentGenerator from '../component/component'; + +export interface NormalizedSchema extends Schema { + name: string; + fileName: string; + projectRoot: string; + routePath: string; + projectDirectory: string; + parsedTags: string[]; + appMain?: string; + appSourceRoot?: string; +} + +export async function libraryGenerator(host: Tree, schema: Schema) { + let installTask: GeneratorCallback; + + const options = normalizeOptions(host, schema); + if (options.publishable === true && !schema.importPath) { + throw new Error( + `For publishable libs you have to provide a proper "--importPath" which needs to be a valid npm package name (e.g. my-awesome-lib or @myorg/my-lib)` + ); + } + if (!options.component) { + options.style = 'none'; + } + + installTask = await init(host, { + ...options, + e2eTestRunner: 'none', + skipFormat: true, + }); + + addProject(host, options); + await addLinting(host, options); + createFiles(host, options); + + if (!options.skipTsConfig) { + updateTsConfig(host, options); + } + + if (options.unitTestRunner === 'jest') { + await jestProjectGenerator(host, { + project: options.name, + setupFile: 'none', + supportTsx: true, + skipSerializers: true, + babelJest: true, + }); + updateBabelJestConfig(host, options.projectRoot, (json) => { + if (options.style === 'styled-jsx') { + json.plugins = (json.plugins || []).concat('styled-jsx/babel'); + } + return json; + }); + } + + if (options.component) { + await componentGenerator(host, { + name: options.name, + project: options.name, + flat: true, + style: options.style, + skipTests: options.unitTestRunner === 'none', + export: true, + routing: options.routing, + js: options.js, + pascalCaseFiles: options.pascalCaseFiles, + }); + } + + if (options.publishable || options.buildable) { + updateLibPackageNpmScope(host, options); + } + + await addDependenciesToPackageJson( + host, + { + react: reactVersion, + 'react-dom': reactDomVersion, + }, + {} + ); + + updateAppRoutes(host, options); + + if (!options.skipFormat) { + await formatFiles(host); + } + + return installTask; +} + +async function addLinting(host: Tree, options: NormalizedSchema) { + let installTask: GeneratorCallback; + installTask = await lintProjectGenerator(host, { + linter: options.linter, + project: options.name, + tsConfigPaths: [ + joinPathFragments(options.projectRoot, 'tsconfig.lib.json'), + ], + eslintFilePatterns: [`${options.projectRoot}/**/*.{ts,tsx,js,jsx}`], + skipFormat: true, + }); + + if (options.linter === Linter.TsLint) { + return; + } + + updateJson( + host, + joinPathFragments(options.projectRoot, '.eslintrc.json'), + (json) => { + json.extends = [...reactEslintJson.extends, ...json.extends]; + return json; + } + ); + + installTask = await addDependenciesToPackageJson( + host, + extraEslintDependencies.dependencies, + extraEslintDependencies.devDependencies + ); + + return installTask; +} + +function addProject(host: Tree, options: NormalizedSchema) { + const targets: { [key: string]: any } = {}; + + if (options.publishable || options.buildable) { + const { libsDir } = getWorkspaceLayout(host); + + const external = ['react', 'react-dom']; + // Also exclude CSS-in-JS packages from build + if ( + options.style !== 'css' && + options.style !== 'scss' && + options.style !== 'styl' && + options.style !== 'less' && + options.style !== 'none' + ) { + external.push( + ...Object.keys(CSS_IN_JS_DEPENDENCIES[options.style].dependencies) + ); + } + targets.build = { + builder: '@nrwl/web:package', + outputs: ['{options.outputPath}'], + options: { + outputPath: `dist/${libsDir}/${options.projectDirectory}`, + tsConfig: `${options.projectRoot}/tsconfig.lib.json`, + project: `${options.projectRoot}/package.json`, + entryFile: maybeJs(options, `${options.projectRoot}/src/index.ts`), + external, + babelConfig: `@nrwl/react/plugins/bundle-babel`, + rollupConfig: `@nrwl/react/plugins/bundle-rollup`, + assets: [ + { + glob: 'README.md', + input: '.', + output: '.', + }, + ], + }, + }; + } + + addProjectConfiguration(host, options.name, { + root: options.projectRoot, + sourceRoot: joinPathFragments(options.projectRoot, 'src'), + projectType: 'library', + tags: options.parsedTags, + targets, + }); +} + +function updateTsConfig(host: Tree, options: NormalizedSchema) { + updateJson(host, 'tsconfig.base.json', (json) => { + const c = json.compilerOptions; + c.paths = c.paths || {}; + delete c.paths[options.name]; + + if (c.paths[options.importPath]) { + throw new Error( + `You already have a library using the import path "${options.importPath}". Make sure to specify a unique one.` + ); + } + + const { libsDir } = getWorkspaceLayout(host); + + c.paths[options.importPath] = [ + maybeJs(options, `${libsDir}/${options.projectDirectory}/src/index.ts`), + ]; + + return json; + }); +} + +function createFiles(host: Tree, options: NormalizedSchema) { + generateFiles( + host, + joinPathFragments(__dirname, './files/lib'), + options.projectRoot, + { + ...options, + ...names(options.name), + tmpl: '', + offsetFromRoot: offsetFromRoot(options.projectRoot), + } + ); + + if (!options.publishable && !options.buildable) { + host.delete(`${options.projectRoot}/package.json`); + } + + if (options.js) { + toJS(host); + } +} + +function updateAppRoutes(host: Tree, options: NormalizedSchema) { + if (!options.appMain || !options.appSourceRoot) { + return; + } + + const { content, source } = readComponent(host, options.appMain); + + const componentImportPath = findComponentImportPath('App', source); + + if (!componentImportPath) { + throw new Error( + `Could not find App component in ${options.appMain} (Hint: you can omit --appProject, or make sure App exists)` + ); + } + + const appComponentPath = joinPathFragments( + options.appSourceRoot, + maybeJs(options, `${componentImportPath}.tsx`) + ); + + addDependenciesToPackageJson( + host, + { 'react-router-dom': reactRouterDomVersion }, + { '@types/react-router-dom': typesReactRouterDomVersion } + ); + + // addBrowserRouterToMain + const isRouterPresent = content.match(/react-router-dom/); + if (!isRouterPresent) { + const changes = applyChangesToString( + content, + addBrowserRouter(options.appMain, source) + ); + host.write(options.appMain, changes); + } + + // addInitialAppRoutes + { + const { + content: componentContent, + source: componentSource, + } = readComponent(host, appComponentPath); + const isComponentRouterPresent = componentContent.match(/react-router-dom/); + if (!isComponentRouterPresent) { + const changes = applyChangesToString( + componentContent, + addInitialRoutes(appComponentPath, componentSource) + ); + host.write(appComponentPath, changes); + } + } + + // addNewAppRoute + { + const { + content: componentContent, + source: componentSource, + } = readComponent(host, appComponentPath); + const { npmScope } = getWorkspaceLayout(host); + const changes = applyChangesToString( + componentContent, + addRoute(appComponentPath, componentSource, { + routePath: options.routePath, + componentName: names(options.name).className, + moduleName: `@${npmScope}/${options.projectDirectory}`, + }) + ); + host.write(appComponentPath, changes); + } +} + +function readComponent( + host: Tree, + path: string +): { content: string; source: ts.SourceFile } { + if (!host.exists(path)) { + throw new Error(`Cannot find ${path}`); + } + + const content = host.read(path).toString('utf-8'); + + const source = ts.createSourceFile( + path, + content, + ts.ScriptTarget.Latest, + true + ); + + return { content, source }; +} + +function normalizeOptions(host: Tree, options: Schema): NormalizedSchema { + const name = names(options.name).fileName; + const projectDirectory = options.directory + ? `${names(options.directory).fileName}/${name}` + : name; + + const projectName = projectDirectory.replace(new RegExp('/', 'g'), '-'); + const fileName = projectName; + const { libsDir, npmScope } = getWorkspaceLayout(host); + const projectRoot = joinPathFragments(`${libsDir}/${projectDirectory}`); + + const parsedTags = options.tags + ? options.tags.split(',').map((s) => s.trim()) + : []; + + const importPath = options.importPath || `@${npmScope}/${projectDirectory}`; + + const normalized: NormalizedSchema = { + ...options, + fileName, + routePath: `/${name}`, + name: projectName, + projectRoot, + projectDirectory, + parsedTags, + importPath, + }; + + if (options.appProject) { + const appProjectConfig = getProjects(host).get(options.appProject); + + if (appProjectConfig.projectType !== 'application') { + throw new Error( + `appProject expected type of "application" but got "${appProjectConfig.projectType}"` + ); + } + + try { + normalized.appMain = appProjectConfig.targets.build.options.main; + normalized.appSourceRoot = normalizePath(appProjectConfig.sourceRoot); + } catch (e) { + throw new Error( + `Could not locate project main for ${options.appProject}` + ); + } + } + + assertValidStyle(normalized.style); + + return normalized; +} + +function updateLibPackageNpmScope(host: Tree, options: NormalizedSchema) { + return updateJson(host, `${options.projectRoot}/package.json`, (json) => { + json.name = options.importPath; + return json; + }); +} + +function maybeJs(options: NormalizedSchema, path: string): string { + return options.js && (path.endsWith('.ts') || path.endsWith('.tsx')) + ? path.replace(/\.tsx?$/, '.js') + : path; +} + +export default libraryGenerator; +export const librarySchematic = convertNxGenerator(libraryGenerator); diff --git a/packages/react/src/schematics/library/schema.d.ts b/packages/react/src/generators/library/schema.d.ts similarity index 74% rename from packages/react/src/schematics/library/schema.d.ts rename to packages/react/src/generators/library/schema.d.ts index f65503691c..75172c60c0 100644 --- a/packages/react/src/schematics/library/schema.d.ts +++ b/packages/react/src/generators/library/schema.d.ts @@ -1,10 +1,10 @@ -import { Linter } from '@nrwl/workspace'; -import { SupportedStyles } from 'packages/react/typings/style'; +import { SupportedStyles } from '../../../typings/style'; +import { Linter } from '@nrwl/linter'; export interface Schema { name: string; directory?: string; - style?: SupportedStyles; + style: SupportedStyles; skipTsConfig: boolean; skipFormat: boolean; tags?: string; diff --git a/packages/react/src/schematics/library/schema.json b/packages/react/src/generators/library/schema.json similarity index 100% rename from packages/react/src/schematics/library/schema.json rename to packages/react/src/generators/library/schema.json diff --git a/packages/react/src/schematics/redux/files/__directory__/__fileName__.slice.spec.ts__tmpl__ b/packages/react/src/generators/redux/files/__directory__/__fileName__.slice.spec.ts__tmpl__ similarity index 100% rename from packages/react/src/schematics/redux/files/__directory__/__fileName__.slice.spec.ts__tmpl__ rename to packages/react/src/generators/redux/files/__directory__/__fileName__.slice.spec.ts__tmpl__ diff --git a/packages/react/src/schematics/redux/files/__directory__/__fileName__.slice.ts__tmpl__ b/packages/react/src/generators/redux/files/__directory__/__fileName__.slice.ts__tmpl__ similarity index 100% rename from packages/react/src/schematics/redux/files/__directory__/__fileName__.slice.ts__tmpl__ rename to packages/react/src/generators/redux/files/__directory__/__fileName__.slice.ts__tmpl__ diff --git a/packages/react/src/generators/redux/redux.spec.ts b/packages/react/src/generators/redux/redux.spec.ts new file mode 100644 index 0000000000..1d198c4d0b --- /dev/null +++ b/packages/react/src/generators/redux/redux.spec.ts @@ -0,0 +1,95 @@ +import { readJson, Tree } from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { applicationGenerator, libraryGenerator } from '@nrwl/react'; +import { Linter } from '@nrwl/linter'; +import { reduxGenerator } from './redux'; + +describe('redux', () => { + let appTree: Tree; + + beforeEach(async () => { + appTree = createTreeWithEmptyWorkspace(); + await libraryGenerator(appTree, { + name: 'my-lib', + linter: Linter.EsLint, + skipFormat: false, + skipTsConfig: false, + style: 'css', + unitTestRunner: 'jest', + }); + }); + + it('should add dependencies', async () => { + await reduxGenerator(appTree, { + name: 'my-slice', + project: 'my-lib', + }); + + const packageJson = readJson(appTree, '/package.json'); + expect(packageJson.dependencies['@reduxjs/toolkit']).toBeDefined(); + expect(packageJson.dependencies['react-redux']).toBeDefined(); + }); + + it('should add slice and spec files', async () => { + await reduxGenerator(appTree, { + name: 'my-slice', + project: 'my-lib', + }); + + expect( + appTree.exists('/libs/my-lib/src/lib/my-slice.slice.ts') + ).toBeTruthy(); + expect( + appTree.exists('/libs/my-lib/src/lib/my-slice.slice.spec.ts') + ).toBeTruthy(); + }); + + describe('--appProject', () => { + it('should configure app main', async () => { + await applicationGenerator(appTree, { + babelJest: false, + e2eTestRunner: 'none', + linter: Linter.EsLint, + skipFormat: true, + style: 'css', + unitTestRunner: 'none', + name: 'my-app', + }); + await reduxGenerator(appTree, { + name: 'my-slice', + project: 'my-lib', + appProject: 'my-app', + }); + await reduxGenerator(appTree, { + name: 'another-slice', + project: 'my-lib', + appProject: 'my-app', + }); + await reduxGenerator(appTree, { + name: 'third-slice', + project: 'my-lib', + appProject: 'my-app', + }); + + const main = appTree.read('/apps/my-app/src/main.tsx').toString(); + expect(main).toContain('@reduxjs/toolkit'); + expect(main).toContain('configureStore'); + expect(main).toContain('[THIRD_SLICE_FEATURE_KEY]: thirdSliceReducer,'); + expect(main).toContain( + '[ANOTHER_SLICE_FEATURE_KEY]: anotherSliceReducer,' + ); + expect(main).toContain('[MY_SLICE_FEATURE_KEY]: mySliceReducer'); + expect(main).toMatch(//); + }); + + it('should throw error for lib project', async () => { + await expect( + reduxGenerator(appTree, { + name: 'my-slice', + project: 'my-lib', + appProject: 'my-lib', + }) + ).rejects.toThrow(/Expected m/); + }); + }); +}); diff --git a/packages/react/src/generators/redux/redux.ts b/packages/react/src/generators/redux/redux.ts new file mode 100644 index 0000000000..fdd36fa12d --- /dev/null +++ b/packages/react/src/generators/redux/redux.ts @@ -0,0 +1,201 @@ +import * as path from 'path'; +import * as ts from 'typescript'; +import { + addImport, + addReduxStoreToMain, + updateReduxStore, +} from '../../utils/ast-utils'; +import { + reactReduxVersion, + reduxjsToolkitVersion, + typesReactReduxVersion, +} from '../../utils/versions'; +import { NormalizedSchema, Schema } from './schema'; +import { + addDependenciesToPackageJson, + applyChangesToString, + convertNxGenerator, + formatFiles, + generateFiles, + getProjects, + joinPathFragments, + names, + readJson, + toJS, + Tree, +} from '@nrwl/devkit'; + +export async function reduxGenerator(host: Tree, schema: Schema) { + const options = normalizeOptions(host, schema); + generateReduxFiles(host, options); + addExportsToBarrel(host, options); + addReduxPackageDependencies(host); + addStoreConfiguration(host, options); + updateReducerConfiguration(host, options); + + await formatFiles(host); +} + +function generateReduxFiles(host: Tree, options: NormalizedSchema) { + generateFiles( + host, + joinPathFragments(__dirname, './files'), + options.filesPath, + { + ...options, + tmpl: '', + } + ); + + if (options.js) { + toJS(host); + } +} + +function addReduxPackageDependencies(host: Tree) { + addDependenciesToPackageJson( + host, + { + '@reduxjs/toolkit': reduxjsToolkitVersion, + 'react-redux': reactReduxVersion, + }, + { + '@types/react-redux': typesReactReduxVersion, + } + ); +} + +function addExportsToBarrel(host: Tree, options: NormalizedSchema) { + const indexFilePath = path.join( + options.projectSourcePath, + options.js ? 'index.js' : 'index.ts' + ); + + const buffer = host.read(indexFilePath); + if (!!buffer) { + const indexSource = buffer.toString('utf-8'); + const indexSourceFile = ts.createSourceFile( + indexFilePath, + indexSource, + ts.ScriptTarget.Latest, + true + ); + + const statePath = options.directory + ? `./lib/${options.directory}/${options.fileName}` + : `./lib/${options.fileName}`; + const changes = applyChangesToString( + indexSource, + addImport(indexSourceFile, `export * from '${statePath}.slice';`) + ); + host.write(indexFilePath, changes); + } +} + +function addStoreConfiguration(host: Tree, options: NormalizedSchema) { + if (!options.appProjectSourcePath) { + return; + } + + const mainSource = host.read(options.appMainFilePath).toString(); + if (!mainSource.includes('redux')) { + const mainSourceFile = ts.createSourceFile( + options.appMainFilePath, + mainSource, + ts.ScriptTarget.Latest, + true + ); + const changes = applyChangesToString( + mainSource, + addReduxStoreToMain(options.appMainFilePath, mainSourceFile) + ); + host.write(options.appMainFilePath, changes); + } +} + +function updateReducerConfiguration(host: Tree, options: NormalizedSchema) { + if (!options.appProjectSourcePath) { + return; + } + + const mainSource = host.read(options.appMainFilePath).toString(); + const mainSourceFile = ts.createSourceFile( + options.appMainFilePath, + mainSource, + ts.ScriptTarget.Latest, + true + ); + const changes = applyChangesToString( + mainSource, + updateReduxStore(options.appMainFilePath, mainSourceFile, { + keyName: `${options.constantName}_FEATURE_KEY`, + reducerName: `${options.propertyName}Reducer`, + modulePath: `${options.projectModulePath}`, + }) + ); + host.write(options.appMainFilePath, changes); +} + +function normalizeOptions(host: Tree, options: Schema): NormalizedSchema { + let appProjectSourcePath: string; + let appMainFilePath: string; + const extraNames = names(options.name); + const projects = getProjects(host); + const project = projects.get(options.project); + const { sourceRoot, projectType } = project; + + const tsConfigJson = readJson(host, 'tsconfig.base.json'); + const tsPaths: { [module: string]: string[] } = tsConfigJson.compilerOptions + ? tsConfigJson.compilerOptions.paths || {} + : {}; + const modulePath = + projectType === 'application' + ? options.directory + ? `./app/${options.directory}/${extraNames.fileName}.slice` + : `./app/${extraNames.fileName}.slice` + : Object.keys(tsPaths).find((k) => + tsPaths[k].some((s) => s.includes(sourceRoot)) + ); + + // If --project is set to an app, automatically configure store + // for it without needing to specify --appProject. + options.appProject = + options.appProject || + (projectType === 'application' ? options.project : undefined); + if (options.appProject) { + const appConfig = projects.get(options.appProject); + if (appConfig.projectType !== 'application') { + throw new Error( + `Expected ${options.appProject} to be an application but got ${appConfig.projectType}` + ); + } + appProjectSourcePath = appConfig.sourceRoot; + appMainFilePath = path.join( + appProjectSourcePath, + options.js ? 'main.js' : 'main.tsx' + ); + if (!host.exists(appMainFilePath)) { + throw new Error( + `Could not find ${appMainFilePath} during store configuration` + ); + } + } + return { + ...options, + ...extraNames, + constantName: names(options.name).constantName.toUpperCase(), + directory: names(options.directory ?? '').fileName, + projectType, + projectSourcePath: sourceRoot, + projectModulePath: modulePath, + appProjectSourcePath, + appMainFilePath, + filesPath: joinPathFragments( + sourceRoot, + projectType === 'application' ? 'app' : 'lib' + ), + }; +} + +export default reduxGenerator; +export const reduxSchematic = convertNxGenerator(reduxGenerator); diff --git a/packages/react/src/schematics/redux/schema.d.ts b/packages/react/src/generators/redux/schema.d.ts similarity index 63% rename from packages/react/src/schematics/redux/schema.d.ts rename to packages/react/src/generators/redux/schema.d.ts index 1ec735610b..4d20fcddec 100644 --- a/packages/react/src/schematics/redux/schema.d.ts +++ b/packages/react/src/generators/redux/schema.d.ts @@ -1,20 +1,18 @@ -import { Path } from '@angular-devkit/core'; - export interface Schema { name: string; project: string; - directory: string; - appProject: string; + directory?: string; + appProject?: string; js?: string; } interface NormalizedSchema extends Schema { projectType: string; - projectSourcePath: Path; + projectSourcePath: string; projectModulePath: string; - appProjectSourcePath: Path; + appProjectSourcePath: string; appMainFilePath: string; - filesPath: Path; + filesPath: string; className: string; constantName: string; propertyName: string; diff --git a/packages/react/src/schematics/redux/schema.json b/packages/react/src/generators/redux/schema.json similarity index 100% rename from packages/react/src/schematics/redux/schema.json rename to packages/react/src/generators/redux/schema.json diff --git a/packages/react/src/schematics/stories/schema.json b/packages/react/src/generators/stories/schema.json similarity index 100% rename from packages/react/src/schematics/stories/schema.json rename to packages/react/src/generators/stories/schema.json diff --git a/packages/react/src/generators/stories/stories-app.spec.ts b/packages/react/src/generators/stories/stories-app.spec.ts new file mode 100644 index 0000000000..053305b033 --- /dev/null +++ b/packages/react/src/generators/stories/stories-app.spec.ts @@ -0,0 +1,107 @@ +import { Tree } from '@nrwl/devkit'; +import storiesGenerator from './stories'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import applicationGenerator from '../application/application'; +import { Linter } from '@nrwl/linter'; + +describe('react:stories for applications', () => { + let appTree: Tree; + + beforeEach(async () => { + appTree = await createTestUIApp('test-ui-app'); + + // create another component + appTree.write( + 'apps/test-ui-app/src/app/anothercmp/another-cmp.tsx', + `import React from 'react'; + + import './test.scss'; + + export interface TestProps { + name: string; + displayAge: boolean; + } + + export const Test = (props: TestProps) => { + return ( +
    +

    Welcome to test component, {props.name}

    +
    + ); + }; + + export default Test; + ` + ); + }); + + it('should create the stories', async () => { + await storiesGenerator(appTree, { + project: 'test-ui-app', + generateCypressSpecs: false, + }); + + expect( + appTree.exists('apps/test-ui-app/src/app/app.stories.tsx') + ).toBeTruthy(); + expect( + appTree.exists( + 'apps/test-ui-app/src/app/anothercmp/another-cmp.stories.tsx' + ) + ).toBeTruthy(); + }); + + it('should generate Cypress specs', async () => { + await storiesGenerator(appTree, { + project: 'test-ui-app', + generateCypressSpecs: true, + }); + + expect( + appTree.exists('apps/test-ui-app-e2e/src/integration/app.spec.ts') + ).toBeTruthy(); + expect( + appTree.exists( + 'apps/test-ui-app-e2e/src/integration/another-cmp/another-cmp.spec.ts' + ) + ).toBeTruthy(); + }); + + it('should ignore files that do not contain components', async () => { + // create another component + appTree.write( + 'apps/test-ui-app/src/app/some-utils.js', + `export const add = (a: number, b: number) => a + b;` + ); + + await storiesGenerator(appTree, { + project: 'test-ui-app', + generateCypressSpecs: false, + }); + + // should just create the story and not error, even though there's a js file + // not containing any react component + expect( + appTree.exists('apps/test-ui-app/src/app/app.stories.tsx') + ).toBeTruthy(); + }); +}); + +export async function createTestUIApp( + libName: string, + plainJS = false +): Promise { + let appTree = createTreeWithEmptyWorkspace(); + + await applicationGenerator(appTree, { + babelJest: false, + e2eTestRunner: 'cypress', + linter: Linter.EsLint, + skipFormat: false, + style: 'css', + unitTestRunner: 'none', + name: libName, + js: plainJS, + }); + return appTree; +} diff --git a/packages/react/src/schematics/stories/stories-lib.spec.ts b/packages/react/src/generators/stories/stories-lib.spec.ts similarity index 52% rename from packages/react/src/schematics/stories/stories-lib.spec.ts rename to packages/react/src/generators/stories/stories-lib.spec.ts index 2253116244..78d3a4e3b8 100644 --- a/packages/react/src/schematics/stories/stories-lib.spec.ts +++ b/packages/react/src/generators/stories/stories-lib.spec.ts @@ -1,19 +1,18 @@ -import { Tree, externalSchematic } from '@angular-devkit/schematics'; -import { runSchematic, callRule } from '../../utils/testing'; -import { createEmptyWorkspace } from '@nrwl/workspace/testing'; -import { UnitTestTree } from '@angular-devkit/schematics/testing'; -import { StorybookStoriesSchema } from './stories'; -import { Schema } from 'packages/react/src/schematics/application/schema'; +import { Tree } from '@nrwl/devkit'; +import storiesGenerator from './stories'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import applicationGenerator from '../application/application'; +import { Linter } from '@nrwl/linter'; +import libraryGenerator from '../library/library'; describe('react:stories for libraries', () => { let appTree: Tree; - let tree: UnitTestTree; beforeEach(async () => { appTree = await createTestUILib('test-ui-lib'); // create another component - appTree.create( + appTree.write( 'libs/test-ui-lib/src/lib/anothercmp/another-cmp.tsx', `import React from 'react'; @@ -38,39 +37,34 @@ describe('react:stories for libraries', () => { }); it('should create the stories', async () => { - tree = await runSchematic( - 'stories', - { - project: 'test-ui-lib', - }, - appTree - ); + await storiesGenerator(appTree, { + project: 'test-ui-lib', + generateCypressSpecs: false, + }); expect( - tree.exists('libs/test-ui-lib/src/lib/test-ui-lib.stories.tsx') + appTree.exists('libs/test-ui-lib/src/lib/test-ui-lib.stories.tsx') ).toBeTruthy(); expect( - tree.exists('libs/test-ui-lib/src/lib/anothercmp/another-cmp.stories.tsx') + appTree.exists( + 'libs/test-ui-lib/src/lib/anothercmp/another-cmp.stories.tsx' + ) ).toBeTruthy(); }); it('should generate Cypress specs', async () => { - tree = await runSchematic( - 'stories', - { - project: 'test-ui-lib', - generateCypressSpecs: true, - }, - appTree - ); + await storiesGenerator(appTree, { + project: 'test-ui-lib', + generateCypressSpecs: true, + }); expect( - tree.exists( + appTree.exists( 'apps/test-ui-lib-e2e/src/integration/test-ui-lib/test-ui-lib.spec.ts' ) ).toBeTruthy(); expect( - tree.exists( + appTree.exists( 'apps/test-ui-lib-e2e/src/integration/another-cmp/another-cmp.spec.ts' ) ).toBeTruthy(); @@ -80,23 +74,20 @@ describe('react:stories for libraries', () => { it('should ignore files that do not contain components', async () => { // create another component - appTree.create( + appTree.write( 'libs/test-ui-lib/src/lib/some-utils.js', `export const add = (a: number, b: number) => a + b;` ); - tree = await runSchematic( - 'stories', - { - project: 'test-ui-lib', - }, - appTree - ); + await storiesGenerator(appTree, { + project: 'test-ui-lib', + generateCypressSpecs: false, + }); // should just create the story and not error, even though there's a js file // not containing any react component expect( - tree.exists('libs/test-ui-lib/src/lib/test-ui-lib.stories.tsx') + appTree.exists('libs/test-ui-lib/src/lib/test-ui-lib.stories.tsx') ).toBeTruthy(); }); }); @@ -105,23 +96,30 @@ export async function createTestUILib( libName: string, plainJS = false ): Promise { - let appTree = Tree.empty(); - appTree = createEmptyWorkspace(appTree); - appTree = await callRule( - externalSchematic('@nrwl/react', 'library', { - name: libName, - }), - appTree - ); + let appTree = createTreeWithEmptyWorkspace(); + + await libraryGenerator(appTree, { + linter: Linter.EsLint, + component: true, + skipFormat: true, + skipTsConfig: false, + style: 'css', + unitTestRunner: 'none', + name: libName, + }); // create some Nx app that we'll use to generate the cypress // spec into it. We don't need a real Cypress setup - appTree = await callRule( - externalSchematic('@nrwl/react', 'application', { - name: `${libName}-e2e`, - js: plainJS, - } as Partial), - appTree - ); + + await applicationGenerator(appTree, { + babelJest: false, + e2eTestRunner: 'none', + linter: Linter.EsLint, + skipFormat: false, + style: 'css', + unitTestRunner: 'none', + name: libName + '-e2e', + js: plainJS, + }); return appTree; } diff --git a/packages/react/src/generators/stories/stories.ts b/packages/react/src/generators/stories/stories.ts new file mode 100644 index 0000000000..2cdf903de2 --- /dev/null +++ b/packages/react/src/generators/stories/stories.ts @@ -0,0 +1,122 @@ +import componentStoryGenerator from '../component-story/component-story'; +import componentCypressSpecGenerator from '../component-cypress-spec/component-cypress-spec'; +import { getComponentName } from '../../utils/ast-utils'; +import * as ts from 'typescript'; +import { + convertNxGenerator, + getProjects, + joinPathFragments, + ProjectType, + Tree, +} from '@nrwl/devkit'; +import { join } from 'path'; + +export interface StorybookStoriesSchema { + project: string; + generateCypressSpecs: boolean; + js?: boolean; +} + +export function projectRootPath( + tree: Tree, + sourceRoot: string, + projectType: ProjectType +): string { + let projectDir = ''; + if (projectType === 'application') { + // apps/test-app/src/app + projectDir = 'app'; + } else if (projectType == 'library') { + // libs/test-lib/src/lib + projectDir = 'lib'; + } + + return joinPathFragments(sourceRoot, projectDir); +} + +function containsComponentDeclaration( + tree: Tree, + componentPath: string +): boolean { + const contents = tree.read(componentPath); + if (!contents) { + throw new Error(`Failed to read ${componentPath}`); + } + + const sourceFile = ts.createSourceFile( + componentPath, + contents.toString(), + ts.ScriptTarget.Latest, + true + ); + + return !!getComponentName(sourceFile); +} + +export async function createAllStories( + tree: Tree, + projectName: string, + generateCypressSpecs: boolean, + js: boolean +) { + const projects = getProjects(tree); + const project = projects.get(projectName); + + const { sourceRoot, projectType } = project; + const libPath = projectRootPath(tree, sourceRoot, projectType); + + let componentPaths: string[] = []; + tree.listChanges().forEach((fileChange) => { + const filePath = fileChange.path; + + if (!filePath.startsWith(libPath) || fileChange.type === 'DELETE') { + return; + } + + if ( + (filePath.endsWith('.tsx') && !filePath.endsWith('.spec.tsx')) || + (filePath.endsWith('.js') && !filePath.endsWith('.spec.js')) || + (filePath.endsWith('.jsx') && !filePath.endsWith('.spec.jsx')) + ) { + componentPaths.push(filePath); + } + }); + + await Promise.all( + componentPaths.map(async (componentPath) => { + const relativeCmpDir = componentPath.replace(join(sourceRoot, '/'), ''); + + if (!containsComponentDeclaration(tree, componentPath)) { + return; + } + + await componentStoryGenerator(tree, { + componentPath: relativeCmpDir, + project: projectName, + }); + + if (generateCypressSpecs) { + await componentCypressSpecGenerator(tree, { + project: projectName, + componentPath: relativeCmpDir, + js, + }); + } + }) + ); +} + +export async function storiesGenerator( + host: Tree, + schema: StorybookStoriesSchema +) { + await createAllStories( + host, + schema.project, + schema.generateCypressSpecs, + schema.js + ); +} + +export default storiesGenerator; +export const storiesSchematic = convertNxGenerator(storiesGenerator); diff --git a/packages/react/src/generators/storybook-configuration/configuration.spec.ts b/packages/react/src/generators/storybook-configuration/configuration.spec.ts new file mode 100644 index 0000000000..cc1e757f41 --- /dev/null +++ b/packages/react/src/generators/storybook-configuration/configuration.spec.ts @@ -0,0 +1,166 @@ +import * as fileUtils from '@nrwl/workspace/src/core/file-utils'; +import { Tree } from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import libraryGenerator from '../library/library'; +import { Linter } from '@nrwl/linter'; +import applicationGenerator from '../application/application'; +import componentGenerator from '../component/component'; +import storybookConfigurationGenerator from './configuration'; + +describe('react:storybook-configuration', () => { + let appTree; + + beforeEach(async () => { + jest.spyOn(fileUtils, 'readPackageJson').mockReturnValue({ + devDependencies: { + '@storybook/addon-essentials': '^6.0.21', + '@storybook/react': '^6.0.21', + }, + }); + }); + + it('should configure everything at once', async () => { + appTree = await createTestUILib('test-ui-lib'); + await storybookConfigurationGenerator(appTree, { + name: 'test-ui-lib', + configureCypress: true, + }); + + expect(appTree.exists('libs/test-ui-lib/.storybook/main.js')).toBeTruthy(); + expect( + appTree.exists('libs/test-ui-lib/.storybook/tsconfig.json') + ).toBeTruthy(); + expect(appTree.exists('apps/test-ui-lib-e2e/cypress.json')).toBeTruthy(); + }); + + it('should generate stories for components', async () => { + appTree = await createTestUILib('test-ui-lib'); + await storybookConfigurationGenerator(appTree, { + name: 'test-ui-lib', + generateStories: true, + configureCypress: false, + }); + + expect( + appTree.exists('libs/test-ui-lib/src/lib/test-ui-lib.stories.tsx') + ).toBeTruthy(); + }); + + it('should generate stories for components written in plain JS', async () => { + appTree = await createTestUILib('test-ui-lib', true); + + appTree.write( + 'libs/test-ui-lib/src/lib/test-ui-libplain.js', + `import React from 'react'; + + import './test.scss'; + + export const Test = (props) => { + return ( +
    +

    Welcome to test component

    +
    + ); + }; + + export default Test; + ` + ); + await storybookConfigurationGenerator(appTree, { + name: 'test-ui-lib', + generateCypressSpecs: true, + generateStories: true, + configureCypress: false, + js: true, + }); + + expect( + appTree.exists('libs/test-ui-lib/src/lib/test-ui-libplain.stories.js') + ).toBeTruthy(); + }); + + it('should configure everything at once', async () => { + appTree = await createTestAppLib('test-ui-app'); + await storybookConfigurationGenerator(appTree, { + name: 'test-ui-app', + configureCypress: true, + }); + + expect(appTree.exists('apps/test-ui-app/.storybook/main.js')).toBeTruthy(); + expect( + appTree.exists('apps/test-ui-app/.storybook/tsconfig.json') + ).toBeTruthy(); + + /** + * Note on the removal of + * expect(tree.exists('apps/test-ui-app-e2e/cypress.json')).toBeTruthy(); + * + * When calling createTestAppLib() we do not generate an e2e suite. + * The storybook schematic for apps does not generate e2e test. + * So, there exists no test-ui-app-e2e! + */ + }); + + it('should generate stories for components', async () => { + appTree = await createTestAppLib('test-ui-app'); + await storybookConfigurationGenerator(appTree, { + name: 'test-ui-app', + generateStories: true, + configureCypress: false, + }); + + // Currently the auto-generate stories feature only picks up components under the 'lib' directory. + // In our 'createTestAppLib' function, we call @nrwl/react:component to generate a component + // under the specified 'lib' directory + expect( + appTree.exists( + 'apps/test-ui-app/src/app/my-component/my-component.stories.tsx' + ) + ).toBeTruthy(); + }); +}); + +export async function createTestUILib( + libName: string, + plainJS = false +): Promise { + let appTree = createTreeWithEmptyWorkspace(); + + await libraryGenerator(appTree, { + linter: Linter.EsLint, + component: true, + skipFormat: true, + skipTsConfig: false, + style: 'css', + unitTestRunner: 'none', + name: libName, + }); + return appTree; +} + +export async function createTestAppLib( + libName: string, + plainJS = false +): Promise { + let appTree = createTreeWithEmptyWorkspace(); + + await applicationGenerator(appTree, { + babelJest: false, + e2eTestRunner: 'none', + linter: Linter.EsLint, + skipFormat: false, + style: 'css', + unitTestRunner: 'none', + name: libName, + js: plainJS, + }); + + await componentGenerator(appTree, { + name: 'my-component', + project: libName, + directory: 'app', + style: 'css', + }); + + return appTree; +} diff --git a/packages/react/src/generators/storybook-configuration/configuration.ts b/packages/react/src/generators/storybook-configuration/configuration.ts new file mode 100644 index 0000000000..12c8d5e57c --- /dev/null +++ b/packages/react/src/generators/storybook-configuration/configuration.ts @@ -0,0 +1,35 @@ +import { StorybookConfigureSchema } from './schema'; +import storiesGenerator from '../stories/stories'; +import { convertNxGenerator, Tree } from '@nrwl/devkit'; +import { configurationGenerator } from '@nrwl/storybook'; + +async function generateStories(host: Tree, schema: StorybookConfigureSchema) { + await storiesGenerator(host, { + project: schema.name, + generateCypressSpecs: + schema.configureCypress && schema.generateCypressSpecs, + js: schema.js, + }); +} + +export async function storybookConfigurationGenerator( + host: Tree, + schema: StorybookConfigureSchema +) { + await configurationGenerator(host, { + name: schema.name, + uiFramework: '@storybook/react', + configureCypress: schema.configureCypress, + js: schema.js, + linter: schema.linter, + }); + + if (schema.generateStories) { + await generateStories(host, schema); + } +} + +export default storybookConfigurationGenerator; +export const storybookConfigurationSchematic = convertNxGenerator( + storybookConfigurationGenerator +); diff --git a/packages/react/src/schematics/storybook-configuration/schema.d.ts b/packages/react/src/generators/storybook-configuration/schema.d.ts similarity index 81% rename from packages/react/src/schematics/storybook-configuration/schema.d.ts rename to packages/react/src/generators/storybook-configuration/schema.d.ts index f1d8197a24..2914ba947f 100644 --- a/packages/react/src/schematics/storybook-configuration/schema.d.ts +++ b/packages/react/src/generators/storybook-configuration/schema.d.ts @@ -1,4 +1,4 @@ -import { Linter } from '@nrwl/workspace'; +import { Linter } from '@nrwl/linter'; export interface StorybookConfigureSchema { name: string; diff --git a/packages/react/src/schematics/storybook-configuration/schema.json b/packages/react/src/generators/storybook-configuration/schema.json similarity index 100% rename from packages/react/src/schematics/storybook-configuration/schema.json rename to packages/react/src/generators/storybook-configuration/schema.json diff --git a/packages/react/src/generators/storybook-migrate-defaults-5-to-6/migrate-defaults-5-to-6.spec.ts b/packages/react/src/generators/storybook-migrate-defaults-5-to-6/migrate-defaults-5-to-6.spec.ts new file mode 100644 index 0000000000..a318448370 --- /dev/null +++ b/packages/react/src/generators/storybook-migrate-defaults-5-to-6/migrate-defaults-5-to-6.spec.ts @@ -0,0 +1,45 @@ +import { createTestUILib } from '../stories/stories-lib.spec'; +import { storybookVersion } from '@nrwl/storybook'; +import { readJson, Tree, updateJson } from '@nrwl/devkit'; +import storybookConfigurationGenerator from '../storybook-configuration/configuration'; +import { storybookMigration5to6Generator } from '@nrwl/react'; + +describe('migrate-defaults-5-to-6 schematic', () => { + let appTree: Tree; + + beforeEach(async () => { + appTree = await createTestUILib('test-ui-lib'); + + updateJson(appTree, 'package.json', (json) => { + return { + ...json, + devDependencies: { + ...json.devDependencies, + '@nrwl/storybook': '10.4.0', + '@nrwl/workspace': '10.4.0', + '@storybook/addon-knobs': '^5.3.8', + '@storybook/react': '^5.3.8', + }, + }; + }); + + await storybookConfigurationGenerator(appTree, { + name: 'test-ui-lib', + configureCypress: false, + generateCypressSpecs: false, + generateStories: false, + }); + }); + + it('should update the correct dependencies', async () => { + storybookMigration5to6Generator(appTree, { all: true }); + const packageJson = readJson(appTree, 'package.json'); + // general deps + expect(packageJson.devDependencies['@storybook/react']).toEqual( + storybookVersion + ); + expect(packageJson.devDependencies['@storybook/addon-knobs']).toEqual( + storybookVersion + ); + }); +}); diff --git a/packages/react/src/generators/storybook-migrate-defaults-5-to-6/migrate-defaults-5-to-6.ts b/packages/react/src/generators/storybook-migrate-defaults-5-to-6/migrate-defaults-5-to-6.ts new file mode 100644 index 0000000000..405b821445 --- /dev/null +++ b/packages/react/src/generators/storybook-migrate-defaults-5-to-6/migrate-defaults-5-to-6.ts @@ -0,0 +1,20 @@ +import { StorybookMigrateDefault5to6Schema } from './schema'; + +import { convertNxGenerator, Tree } from '@nrwl/devkit'; +import { migrateDefaultsGenerator } from '@nrwl/storybook'; + +export function storybookMigration5to6Generator( + host: Tree, + schema: StorybookMigrateDefault5to6Schema +) { + return migrateDefaultsGenerator(host, { + name: schema.name, + all: schema.all, + keepOld: schema.keepOld, + }); +} + +export default storybookMigration5to6Generator; +export const storybookMigration5to6Schematic = convertNxGenerator( + storybookMigration5to6Generator +); diff --git a/packages/react/src/schematics/storybook-migrate-defaults-5-to-6/schema.d.ts b/packages/react/src/generators/storybook-migrate-defaults-5-to-6/schema.d.ts similarity index 100% rename from packages/react/src/schematics/storybook-migrate-defaults-5-to-6/schema.d.ts rename to packages/react/src/generators/storybook-migrate-defaults-5-to-6/schema.d.ts diff --git a/packages/react/src/schematics/storybook-migrate-defaults-5-to-6/schema.json b/packages/react/src/generators/storybook-migrate-defaults-5-to-6/schema.json similarity index 100% rename from packages/react/src/schematics/storybook-migrate-defaults-5-to-6/schema.json rename to packages/react/src/generators/storybook-migrate-defaults-5-to-6/schema.json diff --git a/packages/react/src/rules/add-styled-dependencies.ts b/packages/react/src/rules/add-styled-dependencies.ts index 688fbabad5..9b8aa3fd3a 100644 --- a/packages/react/src/rules/add-styled-dependencies.ts +++ b/packages/react/src/rules/add-styled-dependencies.ts @@ -1,13 +1,14 @@ -import { noop, Rule } from '@angular-devkit/schematics'; -import { addDepsToPackageJson } from '@nrwl/workspace'; import { CSS_IN_JS_DEPENDENCIES } from '../utils/styled'; +import { addDependenciesToPackageJson, Tree } from '@nrwl/devkit'; -export function addStyledModuleDependencies(styledModule: string): Rule { +export function addStyledModuleDependencies(host: Tree, styledModule: string) { const extraDependencies = CSS_IN_JS_DEPENDENCIES[styledModule]; - return extraDependencies - ? addDepsToPackageJson( - extraDependencies.dependencies, - extraDependencies.devDependencies - ) - : noop(); + + if (extraDependencies) { + return addDependenciesToPackageJson( + host, + extraDependencies.dependencies, + extraDependencies.devDependencies + ); + } } diff --git a/packages/react/src/rules/update-babel-jest-config.spec.ts b/packages/react/src/rules/update-babel-jest-config.spec.ts index 43369770e9..e8a2f65df1 100644 --- a/packages/react/src/rules/update-babel-jest-config.spec.ts +++ b/packages/react/src/rules/update-babel-jest-config.spec.ts @@ -1,41 +1,31 @@ -import { Tree } from '@angular-devkit/schematics'; -import { createEmptyWorkspace } from '@nrwl/workspace/testing'; -import { readJsonInTree } from '@nrwl/workspace'; +import { readJson, Tree } from '@nrwl/devkit'; import { updateBabelJestConfig } from './update-babel-jest-config'; -import { callRule } from '@nrwl/workspace/src/utils/testing'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; describe('updateBabelJestConfig', () => { let tree: Tree; beforeEach(() => { - tree = Tree.empty(); - tree = createEmptyWorkspace(tree); + tree = createTreeWithEmptyWorkspace(); }); it('should update babel-jest.config.json', async () => { - tree.create('/apps/demo/babel-jest.config.json', JSON.stringify({})); + tree.write('/apps/demo/babel-jest.config.json', JSON.stringify({})); - tree = await callRule( - updateBabelJestConfig('/apps/demo', (json) => { - json.plugins = ['test']; - return json; - }), - tree - ); + updateBabelJestConfig(tree, '/apps/demo', (json) => { + json.plugins = ['test']; + return json; + }); - const config = readJsonInTree(tree, '/apps/demo/babel-jest.config.json'); + const config = readJson(tree, '/apps/demo/babel-jest.config.json'); expect(config.plugins).toEqual(['test']); }); it('should do nothing if project does not use babel jest', async () => { - tree = await callRule( - updateBabelJestConfig('/apps/demo', (json) => { - json.plugins = ['test']; - return json; - }), - tree - ); - + updateBabelJestConfig(tree, '/apps/demo', (json) => { + json.plugins = ['test']; + return json; + }); expect(tree.exists('/apps/demo/babel-jest.config.json')).toBe(false); }); }); diff --git a/packages/react/src/rules/update-babel-jest-config.ts b/packages/react/src/rules/update-babel-jest-config.ts index a05b3ba6e1..67ebf7f27b 100644 --- a/packages/react/src/rules/update-babel-jest-config.ts +++ b/packages/react/src/rules/update-babel-jest-config.ts @@ -1,16 +1,14 @@ -import { noop, Tree } from '@angular-devkit/schematics'; -import { updateJsonInTree } from '@nrwl/workspace'; +import { Tree, updateJson } from '@nrwl/devkit'; type BabelJestConfigUpdater = (json: T) => T; export function updateBabelJestConfig( + host: Tree, projectRoot: string, update: BabelJestConfigUpdater ) { - return (host: Tree) => { - const configPath = `${projectRoot}/babel-jest.config.json`; - return host.exists(configPath) - ? updateJsonInTree(configPath, update) - : noop(); - }; + const configPath = `${projectRoot}/babel-jest.config.json`; + if (host.exists(configPath)) { + updateJson(host, configPath, update); + } } diff --git a/packages/react/src/schematics/application/application.spec.ts b/packages/react/src/schematics/application/application.spec.ts deleted file mode 100644 index b38983e87c..0000000000 --- a/packages/react/src/schematics/application/application.spec.ts +++ /dev/null @@ -1,693 +0,0 @@ -import { Tree } from '@angular-devkit/schematics'; -import { createEmptyWorkspace } from '@nrwl/workspace/testing'; -import * as stripJsonComments from 'strip-json-comments'; -import { NxJson, readJsonInTree } from '@nrwl/workspace'; -import { runSchematic } from '../../utils/testing'; - -describe('app', () => { - let appTree: Tree; - - beforeEach(() => { - appTree = Tree.empty(); - appTree = createEmptyWorkspace(appTree); - }); - - describe('not nested', () => { - it('should update workspace.json', async () => { - const tree = await runSchematic('app', { name: 'myApp' }, appTree); - const workspaceJson = readJsonInTree(tree, '/workspace.json'); - - expect(workspaceJson.projects['my-app'].root).toEqual('apps/my-app'); - expect(workspaceJson.projects['my-app-e2e'].root).toEqual( - 'apps/my-app-e2e' - ); - expect(workspaceJson.defaultProject).toEqual('my-app'); - }); - - it('should update nx.json', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', tags: 'one,two' }, - appTree - ); - const nxJson = readJsonInTree(tree, '/nx.json'); - expect(nxJson.projects).toEqual({ - 'my-app': { - tags: ['one', 'two'], - }, - 'my-app-e2e': { - tags: [], - implicitDependencies: ['my-app'], - }, - }); - }); - - it('should generate files', async () => { - const tree = await runSchematic('app', { name: 'myApp' }, appTree); - expect(tree.exists('apps/my-app/.babelrc')).toBeTruthy(); - expect(tree.exists('apps/my-app/.browserslistrc')).toBeTruthy(); - expect(tree.exists('apps/my-app/src/main.tsx')).toBeTruthy(); - expect(tree.exists('apps/my-app/src/app/app.tsx')).toBeTruthy(); - expect(tree.exists('apps/my-app/src/app/app.spec.tsx')).toBeTruthy(); - expect(tree.exists('apps/my-app/src/app/app.module.css')).toBeTruthy(); - - const jestConfig = tree.readContent('apps/my-app/jest.config.js'); - expect(jestConfig).toContain('@nrwl/react/plugins/jest'); - - const tsconfig = readJsonInTree(tree, 'apps/my-app/tsconfig.json'); - expect(tsconfig.references).toEqual([ - { - path: './tsconfig.app.json', - }, - { - path: './tsconfig.spec.json', - }, - ]); - - const tsconfigApp = JSON.parse( - stripJsonComments(tree.readContent('apps/my-app/tsconfig.app.json')) - ); - expect(tsconfigApp.compilerOptions.outDir).toEqual('../../dist/out-tsc'); - expect(tsconfigApp.extends).toEqual('./tsconfig.json'); - - const eslintJson = JSON.parse( - stripJsonComments(tree.readContent('apps/my-app/.eslintrc.json')) - ); - expect(eslintJson.extends).toEqual([ - 'plugin:@nrwl/nx/react', - '../../.eslintrc.json', - ]); - - expect(tree.exists('apps/my-app-e2e/cypress.json')).toBeTruthy(); - const tsconfigE2E = JSON.parse( - stripJsonComments(tree.readContent('apps/my-app-e2e/tsconfig.e2e.json')) - ); - expect(tsconfigE2E.compilerOptions.outDir).toEqual('../../dist/out-tsc'); - expect(tsconfigE2E.extends).toEqual('./tsconfig.json'); - }); - }); - - describe('nested', () => { - it('should update workspace.json', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', directory: 'myDir' }, - appTree - ); - const workspaceJson = readJsonInTree(tree, '/workspace.json'); - - expect(workspaceJson.projects['my-dir-my-app'].root).toEqual( - 'apps/my-dir/my-app' - ); - expect(workspaceJson.projects['my-dir-my-app-e2e'].root).toEqual( - 'apps/my-dir/my-app-e2e' - ); - }); - - it('should update nx.json', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', directory: 'myDir', tags: 'one,two' }, - appTree - ); - const nxJson = readJsonInTree(tree, '/nx.json'); - expect(nxJson.projects).toEqual({ - 'my-dir-my-app': { - tags: ['one', 'two'], - }, - 'my-dir-my-app-e2e': { - tags: [], - implicitDependencies: ['my-dir-my-app'], - }, - }); - }); - - it('should generate files', async () => { - const hasJsonValue = ({ path, expectedValue, lookupFn }) => { - const content = tree.readContent(path); - const config = JSON.parse(stripJsonComments(content)); - - expect(lookupFn(config)).toEqual(expectedValue); - }; - const tree = await runSchematic( - 'app', - { name: 'myApp', directory: 'myDir' }, - appTree - ); - - // Make sure these exist - [ - 'apps/my-dir/my-app/src/main.tsx', - 'apps/my-dir/my-app/src/app/app.tsx', - 'apps/my-dir/my-app/src/app/app.spec.tsx', - 'apps/my-dir/my-app/src/app/app.module.css', - ].forEach((path) => { - expect(tree.exists(path)).toBeTruthy(); - }); - - // Make sure these have properties - [ - { - path: 'apps/my-dir/my-app/tsconfig.app.json', - lookupFn: (json) => json.compilerOptions.outDir, - expectedValue: '../../../dist/out-tsc', - }, - { - path: 'apps/my-dir/my-app-e2e/tsconfig.e2e.json', - lookupFn: (json) => json.compilerOptions.outDir, - expectedValue: '../../../dist/out-tsc', - }, - { - path: 'apps/my-dir/my-app/.eslintrc.json', - lookupFn: (json) => json.extends, - expectedValue: ['plugin:@nrwl/nx/react', '../../../.eslintrc.json'], - }, - ].forEach(hasJsonValue); - }); - }); - - it('should create Nx specific template', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', directory: 'myDir' }, - appTree - ); - expect(tree.readContent('apps/my-dir/my-app/src/app/app.tsx')).toBeTruthy(); - expect(tree.readContent('apps/my-dir/my-app/src/app/app.tsx')).toContain( - 'Welcome to my-app' - ); - }); - - describe('--style scss', () => { - it('should generate scss styles', async () => { - const result = await runSchematic( - 'app', - { name: 'myApp', style: 'scss' }, - appTree - ); - expect(result.exists('apps/my-app/src/app/app.module.scss')).toEqual( - true - ); - }); - }); - - it('should setup jest with tsx support', async () => { - const tree = await runSchematic( - 'app', - { - name: 'my-app', - }, - appTree - ); - - expect(tree.readContent('apps/my-app/jest.config.js')).toContain( - `moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],` - ); - }); - - it('should setup jest without serializers', async () => { - const tree = await runSchematic( - 'app', - { - name: 'my-app', - }, - appTree - ); - - expect(tree.readContent('apps/my-app/jest.config.js')).not.toContain( - `'jest-preset-angular/build/AngularSnapshotSerializer.js',` - ); - }); - - it('should setup the nrwl web build builder', async () => { - const tree = await runSchematic( - 'app', - { - name: 'my-app', - }, - appTree - ); - const workspaceJson = readJsonInTree(tree, 'workspace.json'); - const architectConfig = workspaceJson.projects['my-app'].architect; - expect(architectConfig.build.builder).toEqual('@nrwl/web:build'); - expect(architectConfig.build.outputs).toEqual(['{options.outputPath}']); - expect(architectConfig.build.options).toEqual({ - assets: ['apps/my-app/src/favicon.ico', 'apps/my-app/src/assets'], - index: 'apps/my-app/src/index.html', - main: 'apps/my-app/src/main.tsx', - outputPath: 'dist/apps/my-app', - polyfills: 'apps/my-app/src/polyfills.ts', - scripts: [], - styles: ['apps/my-app/src/styles.css'], - tsConfig: 'apps/my-app/tsconfig.app.json', - webpackConfig: '@nrwl/react/plugins/webpack', - }); - expect(architectConfig.build.configurations.production).toEqual({ - optimization: true, - budgets: [ - { - maximumError: '5mb', - maximumWarning: '2mb', - type: 'initial', - }, - ], - extractCss: true, - extractLicenses: true, - fileReplacements: [ - { - replace: 'apps/my-app/src/environments/environment.ts', - with: 'apps/my-app/src/environments/environment.prod.ts', - }, - ], - namedChunks: false, - outputHashing: 'all', - sourceMap: false, - vendorChunk: false, - }); - }); - - it('should setup the nrwl web dev server builder', async () => { - const tree = await runSchematic( - 'app', - { - name: 'my-app', - }, - appTree - ); - const workspaceJson = readJsonInTree(tree, 'workspace.json'); - const architectConfig = workspaceJson.projects['my-app'].architect; - expect(architectConfig.serve.builder).toEqual('@nrwl/web:dev-server'); - expect(architectConfig.serve.options).toEqual({ - buildTarget: 'my-app:build', - }); - expect(architectConfig.serve.configurations.production).toEqual({ - buildTarget: 'my-app:build:production', - }); - }); - - it('should setup the eslint builder', async () => { - const tree = await runSchematic( - 'app', - { - name: 'my-app', - }, - appTree - ); - const workspaceJson = readJsonInTree(tree, 'workspace.json'); - expect(workspaceJson.projects['my-app'].architect.lint).toEqual({ - builder: '@nrwl/linter:eslint', - options: { - lintFilePatterns: ['apps/my-app/**/*.{ts,tsx,js,jsx}'], - }, - }); - }); - - describe('--unit-test-runner none', () => { - it('should not generate test configuration', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', unitTestRunner: 'none' }, - appTree - ); - expect(tree.exists('jest.config.js')).toBeFalsy(); - expect(tree.exists('apps/my-app/src/app/app.spec.tsx')).toBeFalsy(); - expect(tree.exists('apps/my-app/tsconfig.spec.json')).toBeFalsy(); - expect(tree.exists('apps/my-app/jest.config.js')).toBeFalsy(); - const workspaceJson = readJsonInTree(tree, 'workspace.json'); - expect(workspaceJson.projects['my-app'].architect.test).toBeUndefined(); - expect(workspaceJson.projects['my-app'].architect.lint) - .toMatchInlineSnapshot(` - Object { - "builder": "@nrwl/linter:eslint", - "options": Object { - "lintFilePatterns": Array [ - "apps/my-app/**/*.{ts,tsx,js,jsx}", - ], - }, - } - `); - }); - }); - - describe('--e2e-test-runner none', () => { - it('should not generate test configuration', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', e2eTestRunner: 'none' }, - appTree - ); - expect(tree.exists('apps/my-app-e2e')).toBeFalsy(); - const workspaceJson = readJsonInTree(tree, 'workspace.json'); - expect(workspaceJson.projects['my-app-e2e']).toBeUndefined(); - }); - }); - - describe('--pascalCaseFiles', () => { - it('should use upper case app file', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', pascalCaseFiles: true }, - appTree - ); - - expect(tree.exists('apps/my-app/src/app/App.tsx')).toBeTruthy(); - expect(tree.exists('apps/my-app/src/app/App.spec.tsx')).toBeTruthy(); - expect(tree.exists('apps/my-app/src/app/App.module.css')).toBeTruthy(); - }); - }); - - it('should generate functional components by default', async () => { - const tree = await runSchematic('app', { name: 'myApp' }, appTree); - - const appContent = tree.read('apps/my-app/src/app/app.tsx').toString(); - - expect(appContent).not.toMatch(/extends Component/); - }); - - it('should add .eslintrc.json and dependencies', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', linter: 'eslint' }, - appTree - ); - - const eslintJson = readJsonInTree(tree, '/apps/my-app/.eslintrc.json'); - const packageJson = readJsonInTree(tree, '/package.json'); - - expect(eslintJson.extends).toEqual( - expect.arrayContaining(['plugin:@nrwl/nx/react']) - ); - expect(packageJson.devDependencies.eslint).toBeDefined(); - expect(packageJson.devDependencies['@nrwl/linter']).toBeDefined(); - expect(packageJson.devDependencies['@nrwl/eslint-plugin-nx']).toBeDefined(); - expect(packageJson.devDependencies['eslint-plugin-react']).toBeDefined(); - expect( - packageJson.devDependencies['eslint-plugin-react-hooks'] - ).toBeDefined(); - expect( - packageJson.devDependencies['@typescript-eslint/parser'] - ).toBeDefined(); - expect( - packageJson.devDependencies['@typescript-eslint/eslint-plugin'] - ).toBeDefined(); - expect(packageJson.devDependencies['eslint-config-prettier']).toBeDefined(); - }); - - describe('--class-component', () => { - it('should generate class components', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', classComponent: true }, - appTree - ); - - const appContent = tree.read('apps/my-app/src/app/app.tsx').toString(); - - expect(appContent).toMatch(/extends Component/); - }); - }); - - describe('--style none', () => { - it('should not generate any styles', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', style: 'none' }, - appTree - ); - - expect(tree.exists('apps/my-app/src/app/app.tsx')).toBeTruthy(); - expect(tree.exists('apps/my-app/src/app/app.spec.tsx')).toBeTruthy(); - expect(tree.exists('apps/my-app/src/app/app.css')).toBeFalsy(); - expect(tree.exists('apps/my-app/src/app/app.scss')).toBeFalsy(); - expect(tree.exists('apps/my-app/src/app/app.styl')).toBeFalsy(); - expect(tree.exists('apps/my-app/src/app/app.module.css')).toBeFalsy(); - expect(tree.exists('apps/my-app/src/app/app.module.scss')).toBeFalsy(); - expect(tree.exists('apps/my-app/src/app/app.module.styl')).toBeFalsy(); - - const content = tree.read('apps/my-app/src/app/app.tsx').toString(); - expect(content).not.toContain('styled-components'); - expect(content).not.toContain(''); - expect(content).not.toContain('@emotion/styled'); - expect(content).not.toContain(''); - - //for imports - expect(content).not.toContain('app.styl'); - expect(content).not.toContain('app.css'); - expect(content).not.toContain('app.scss'); - expect(content).not.toContain('app.module.styl'); - expect(content).not.toContain('app.module.css'); - expect(content).not.toContain('app.module.scss'); - }); - - it('should set defaults when style: none', async () => { - const tree = await runSchematic( - 'app', - { - name: 'myApp', - style: 'none', - }, - appTree - ); - - const workspaceJson = readJsonInTree(tree, '/workspace.json'); - expect(workspaceJson.schematics['@nrwl/react']).toMatchObject({ - application: { - style: 'none', - }, - component: { - style: 'none', - }, - library: { - style: 'none', - }, - }); - }); - - it('should exclude styles from workspace.json', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', style: 'none' }, - appTree - ); - - const workspaceJson = readJsonInTree(tree, 'workspace.json'); - - expect( - workspaceJson.projects['my-app'].architect.build.options.styles - ).toEqual([]); - }); - }); - - describe('--style styled-components', () => { - it('should use styled-components as the styled API library', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', style: 'styled-components' }, - appTree - ); - - expect( - tree.exists('apps/my-app/src/app/app.styled-components') - ).toBeFalsy(); - expect(tree.exists('apps/my-app/src/app/app.tsx')).toBeTruthy(); - expect( - tree.exists('apps/my-app/src/styles.styled-components') - ).toBeFalsy(); - - const content = tree.read('apps/my-app/src/app/app.tsx').toString(); - expect(content).toContain('styled-component'); - expect(content).toContain(''); - }); - - it('should add dependencies to package.json', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', style: 'styled-components' }, - appTree - ); - - const packageJSON = readJsonInTree(tree, 'package.json'); - expect(packageJSON.dependencies['styled-components']).toBeDefined(); - }); - }); - - describe('--style @emotion/styled', () => { - it('should use @emotion/styled as the styled API library', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', style: '@emotion/styled' }, - appTree - ); - - expect( - tree.exists('apps/my-app/src/app/app.@emotion/styled') - ).toBeFalsy(); - expect(tree.exists('apps/my-app/src/app/app.tsx')).toBeTruthy(); - - const content = tree.read('apps/my-app/src/app/app.tsx').toString(); - expect(content).toContain('@emotion/styled'); - expect(content).toContain(''); - }); - - it('should exclude styles from workspace.json', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', style: '@emotion/styled' }, - appTree - ); - - const workspaceJson = readJsonInTree(tree, 'workspace.json'); - - expect( - workspaceJson.projects['my-app'].architect.build.options.styles - ).toEqual([]); - }); - - it('should add dependencies to package.json', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', style: '@emotion/styled' }, - appTree - ); - - const packageJSON = readJsonInTree(tree, 'package.json'); - expect(packageJSON.dependencies['@emotion/react']).toBeDefined(); - expect(packageJSON.dependencies['@emotion/styled']).toBeDefined(); - }); - }); - - describe('--style styled-jsx', () => { - it('should use styled-jsx as the styled API library', async () => { - const tree = await runSchematic( - 'app', - { name: 'myApp', style: 'styled-jsx' }, - appTree - ); - - expect(tree.exists('apps/my-app/src/app/app.styled-jsx')).toBeFalsy(); - expect(tree.exists('apps/my-app/src/app/app.tsx')).toBeTruthy(); - - const content = tree.read('apps/my-app/src/app/app.tsx').toString(); - expect(content).toContain('