diff --git a/docs/api-react/schematics/library.md b/docs/api-react/schematics/library.md index d4a2cd9b80..4ed1ed864d 100644 --- a/docs/api-react/schematics/library.md +++ b/docs/api-react/schematics/library.md @@ -11,6 +11,12 @@ ng generate library ... ## Options +### appProject + +Type: `string` + +The application project to add the library route to + ### directory Type: `string` @@ -31,12 +37,6 @@ Type: `string` Library name -### parentRoute - -Type: `string` - -Add new route to the parent component as specified by this path - ### pascalCaseFiles Default: `false` diff --git a/packages/react/src/schematics/application/application.ts b/packages/react/src/schematics/application/application.ts index 891fbe7334..8e9e062c79 100644 --- a/packages/react/src/schematics/application/application.ts +++ b/packages/react/src/schematics/application/application.ts @@ -8,6 +8,7 @@ import { move, noop, Rule, + SchematicContext, template, Tree, url @@ -35,7 +36,7 @@ import * as ts from 'typescript'; import { Schema } from './schema'; import { CSS_IN_JS_DEPENDENCIES } from '../../utils/styled'; -import { addRouter } from '../../utils/ast-utils'; +import { addRoute, addInitialRoutes } from '../../utils/ast-utils'; import { babelCoreVersion, babelLoaderVersion, @@ -61,7 +62,7 @@ interface NormalizedSchema extends Schema { } export default function(schema: Schema): Rule { - return (host: Tree) => { + return (host: Tree, context: SchematicContext) => { const options = normalizeOptions(host, schema); return chain([ @@ -89,7 +90,7 @@ export default function(schema: Schema): Rule { }) : noop(), addStyledModuleDependencies(options), - addRouting(options), + addRouting(options, context), addBabel(options), formatFiles(options) ]); @@ -221,7 +222,10 @@ function addStyledModuleDependencies(options: NormalizedSchema): Rule { : noop(); } -function addRouting(options: NormalizedSchema): Rule { +function addRouting( + options: NormalizedSchema, + context: SchematicContext +): Rule { return options.routing ? chain([ function addRouterToComponent(host: Tree) { @@ -237,7 +241,7 @@ function addRouting(options: NormalizedSchema): Rule { true ); - insert(host, appPath, addRouter(appPath, appSource)); + insert(host, appPath, addInitialRoutes(appPath, appSource, context)); }, addDepsToPackageJson({ 'react-router-dom': reactRouterVersion }, {}) ]) diff --git a/packages/react/src/schematics/library/library.spec.ts b/packages/react/src/schematics/library/library.spec.ts index ad62865983..c80dabc862 100644 --- a/packages/react/src/schematics/library/library.spec.ts +++ b/packages/react/src/schematics/library/library.spec.ts @@ -263,8 +263,8 @@ describe('lib', () => { }); }); - describe('--parentRoute', () => { - it('should add route to parent component', async () => { + describe('--appProject', () => { + it('should add new route to existing routing code', async () => { appTree = await runSchematic( 'app', { name: 'myApp', routing: true }, @@ -275,48 +275,39 @@ describe('lib', () => { 'lib', { name: 'myLib', - parentRoute: 'apps/my-app/src/app/app.tsx' + appProject: 'my-app' }, appTree ); const appSource = tree.read('apps/my-app/src/app/app.tsx').toString(); + const mainSource = tree.read('apps/my-app/src/main.tsx').toString(); - expect(appSource).toMatch(/'); expect(appSource).toContain('@proj/my-lib'); + expect(appSource).toContain('react-router-dom'); + expect(appSource).toMatch(/ { - await expect( - runSchematic( - 'lib', - { - name: 'myLib', - parentRoute: 'does/not/exist.tsx' - }, - appTree - ) - ).rejects.toThrow('Cannot find'); - }); - - it('should add routing to app if it does not exist yet', async () => { - appTree = await runSchematic( - 'app', - { name: 'myApp', routing: false }, - appTree - ); + it('should initialize routes if none were set up then add new route', async () => { + appTree = await runSchematic('app', { name: 'myApp' }, appTree); const tree = await runSchematic( 'lib', { name: 'myLib', - parentRoute: 'apps/my-app/src/app/app.tsx' + appProject: 'my-app' }, appTree ); const appSource = tree.read('apps/my-app/src/app/app.tsx').toString(); + const mainSource = tree.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(/ { - const options = normalizeOptions(schema); + const options = normalizeOptions(host, schema, context); return chain([ addLintFiles(options.projectRoot, options.linter), @@ -68,7 +77,7 @@ export default function(schema: Schema): Rule { export: true, routing: options.routing }), - updateParentRoute(options), + updateAppRoutes(options, context), formatFiles(options) ])(host, context); }; @@ -132,45 +141,77 @@ function updateNxJson(options: NormalizedSchema): Rule { }); } -function updateParentRoute(options: NormalizedSchema): Rule { - return options.parentRoute - ? chain([ - function ensureRouterAdded(host: Tree) { - const { source, content } = readComponent(host, options.parentRoute); - const isRouterPresent = content.match(/react-router-dom/); +function updateAppRoutes( + options: NormalizedSchema, + context: SchematicContext +): Rule { + if (!options.appMain || !options.appSourceRoot) { + return noop(); + } + return (host: Tree) => { + const { source } = readComponent(host, options.appMain); + const componentImportPath = findComponentImportPath('App', source); - if (!isRouterPresent) { - insert( - host, - options.parentRoute, - addRouter(options.parentRoute, source) - ); - return addDepsToPackageJson( - { 'react-router-dom': reactRouterVersion }, - {} - ); - } - }, - function addRouteToComponent(host: Tree) { - const npmScope = getNpmScope(host); - const { source: componentSource } = readComponent( - host, - options.parentRoute - ); + 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 = join( + options.appSourceRoot, + `${componentImportPath}.tsx` + ); + return chain([ + addDepsToPackageJson({ 'react-router-dom': reactRouterVersion }, {}), + function addBrowserRouterToMain(host: Tree) { + const { content, source } = readComponent(host, options.appMain); + const isRouterPresent = content.match(/react-router-dom/); + if (!isRouterPresent) { insert( host, - options.parentRoute, - addRoute(options.parentRoute, componentSource, { - libName: options.name, + options.appMain, + addBrowserRouter(options.appMain, source, context) + ); + } + }, + function addInitialAppRoutes(host: Tree) { + const { content, source } = readComponent(host, appComponentPath); + const isRouterPresent = content.match(/react-router-dom/); + if (!isRouterPresent) { + insert( + host, + appComponentPath, + addInitialRoutes(appComponentPath, source, context) + ); + } + }, + function addNewAppRoute(host: Tree) { + const npmScope = getNpmScope(host); + const { source: componentSource } = readComponent( + host, + appComponentPath + ); + insert( + host, + appComponentPath, + addRoute( + appComponentPath, + componentSource, + { + routePath: options.routePath, componentName: toClassName(options.name), moduleName: `@${npmScope}/${options.projectDirectory}` - }) - ); - }, - addDepsToPackageJson({ 'react-router-dom': reactRouterVersion }, {}) - ]) - : noop(); + }, + context + ) + ); + }, + addDepsToPackageJson({ 'react-router-dom': reactRouterVersion }, {}) + ]); + }; } function readComponent( @@ -193,26 +234,54 @@ function readComponent( return { content, source }; } -function normalizeOptions(options: Schema): NormalizedSchema { +function normalizeOptions( + host: Tree, + options: Schema, + context: SchematicContext +): NormalizedSchema { const name = toFileName(options.name); const projectDirectory = options.directory ? `${toFileName(options.directory)}/${name}` : name; const projectName = projectDirectory.replace(new RegExp('/', 'g'), '-'); - const fileName = options.simpleModuleName ? name : projectName; + const fileName = projectName; const projectRoot = normalize(`libs/${projectDirectory}`); const parsedTags = options.tags ? options.tags.split(',').map(s => s.trim()) : []; - return { + const normalized: NormalizedSchema = { ...options, fileName, + routePath: `/${name}`, name: projectName, projectRoot, projectDirectory, parsedTags }; + + if (options.appProject) { + const appProjectConfig = getProjectConfig(host, options.appProject); + + if (appProjectConfig.projectType !== 'application') { + throw new Error( + `appProject expected type of "application" but got "${ + appProjectConfig.projectType + }"` + ); + } + + try { + normalized.appMain = appProjectConfig.architect.build.options.main; + normalized.appSourceRoot = normalize(appProjectConfig.sourceRoot); + } catch (e) { + throw new Error( + `Could not locate project main for ${options.appProject}` + ); + } + } + + return normalized; } diff --git a/packages/react/src/schematics/library/schema.d.ts b/packages/react/src/schematics/library/schema.d.ts index b33e36c42b..98b954fb6e 100644 --- a/packages/react/src/schematics/library/schema.d.ts +++ b/packages/react/src/schematics/library/schema.d.ts @@ -7,10 +7,9 @@ export interface Schema { skipTsConfig: boolean; skipFormat: boolean; tags?: string; - simpleModuleName: boolean; pascalCaseFiles?: boolean; routing?: boolean; - parentRoute?: string; + appProject?: string; unitTestRunner: 'jest' | 'none'; linter: Linter; } diff --git a/packages/react/src/schematics/library/schema.json b/packages/react/src/schematics/library/schema.json index f2fd70e30c..124658ba05 100644 --- a/packages/react/src/schematics/library/schema.json +++ b/packages/react/src/schematics/library/schema.json @@ -84,9 +84,9 @@ "type": "boolean", "description": "Generate library with routes" }, - "parentRoute": { + "appProject": { "type": "string", - "description": "Add new route to the parent component as specified by this path" + "description": "The application project to add the library route to" } }, "required": ["name"] diff --git a/packages/react/src/utils/ast-utils.spec.ts b/packages/react/src/utils/ast-utils.spec.ts index d6c9ee9ae6..f8ad7e740a 100644 --- a/packages/react/src/utils/ast-utils.spec.ts +++ b/packages/react/src/utils/ast-utils.spec.ts @@ -1,16 +1,18 @@ import * as utils from './ast-utils'; import * as ts from 'typescript'; +import { Tree } from '@angular-devkit/schematics'; +import { insert } from '@nrwl/workspace/src/utils/ast-utils'; describe('react ast-utils', () => { describe('findDefaultExport', () => { it('should find exported variable', () => { - const text = ` + const sourceCode = ` const main = () => {}; export default main; `; const source = ts.createSourceFile( 'test.ts', - text, + sourceCode, ts.ScriptTarget.Latest, true ); @@ -22,13 +24,13 @@ describe('react ast-utils', () => { }); it('should find exported function', () => { - const text = ` + const sourceCode = ` function main() {} export default main; `; const source = ts.createSourceFile( 'test.ts', - text, + sourceCode, ts.ScriptTarget.Latest, true ); @@ -40,12 +42,12 @@ describe('react ast-utils', () => { }); it('should find default export function', () => { - const text = ` + const sourceCode = ` export default function main() {}; `; const source = ts.createSourceFile( 'test.ts', - text, + sourceCode, ts.ScriptTarget.Latest, true ); @@ -56,4 +58,133 @@ describe('react ast-utils', () => { expect(result.name.text).toEqual('main'); }); }); + + describe('addRoute', () => { + let tree: Tree; + let context: any; + + beforeEach(() => { + context = { + warn: jest.fn() + }; + tree = Tree.empty(); + }); + + it('should add links and routes if they are not present', async () => { + const sourceCode = ` +import React from 'react'; +const App = () => ( + <> +

Hello

+ +); +export default App; + `; + tree.create('app.tsx', sourceCode); + const source = ts.createSourceFile( + 'app.tsx', + sourceCode, + ts.ScriptTarget.Latest, + true + ); + + insert( + tree, + 'app.tsx', + utils.addInitialRoutes('app.tsx', source, context) + ); + + const result = tree.read('app.tsx').toString(); + + expect(result).toMatch(/role="navigation"/); + expect(result).toMatch(/ { + const sourceCode = ` +import React from 'react'; +import { Home } from '@example/home'; +const App = () => ( + <> +
+

Hello

+
+
    +
  • Home
  • +
+
+
+

Hello World!

+ + +); +export default App; + `; + tree.create('app.tsx', sourceCode); + const source = ts.createSourceFile( + 'app.tsx', + sourceCode, + ts.ScriptTarget.Latest, + true + ); + + insert( + tree, + 'app.tsx', + utils.addRoute( + 'app.tsx', + source, + { + routePath: '/about', + componentName: 'About', + moduleName: '@example/about' + }, + context + ) + ); + + const result = tree.read('app.tsx').toString(); + + expect(result).toMatch(/
  • { + let tree: Tree; + let context: any; + + beforeEach(() => { + context = { + warn: jest.fn() + }; + tree = Tree.empty(); + }); + + it('should wrap around App component', () => { + const sourceCode = ` +import React from 'react'; +import ReactDOM from 'react-dom'; +import { App } from '@example/my-app'; +ReactDOM.render(, document.getElementById('root')); + `; + tree.create('app.tsx', sourceCode); + const source = ts.createSourceFile( + 'app.tsx', + sourceCode, + ts.ScriptTarget.Latest, + true + ); + + insert( + tree, + 'app.tsx', + utils.addBrowserRouter('app.tsx', source, context) + ); + + const result = tree.read('app.tsx').toString(); + expect(result).toContain(''); + }); + }); }); diff --git a/packages/react/src/utils/ast-utils.ts b/packages/react/src/utils/ast-utils.ts index 179ee6497f..806a4c21a3 100644 --- a/packages/react/src/utils/ast-utils.ts +++ b/packages/react/src/utils/ast-utils.ts @@ -5,17 +5,43 @@ import { InsertChange } from '@nrwl/workspace/src/utils/ast-utils'; import * as ts from 'typescript'; +import { noop, SchematicContext } from '@angular-devkit/schematics'; +import { join } from '@angular-devkit/core'; -export function addRouter(sourcePath: string, source: ts.SourceFile): Change[] { - const jsxClosing = findNodes(source, ts.SyntaxKind.JsxClosingElement); +export function addInitialRoutes( + sourcePath: string, + source: ts.SourceFile, + context: SchematicContext +): Change[] { + const jsxClosingElements = findNodes(source, [ + ts.SyntaxKind.JsxClosingElement, + ts.SyntaxKind.JsxClosingFragment + ]); + const outerMostJsxClosing = jsxClosingElements[jsxClosingElements.length - 1]; - const outerMostJsxClosing = jsxClosing[jsxClosing.length - 1]; + if (!outerMostJsxClosing) { + context.logger.warn( + `Could not find JSX elements in ${sourcePath}; Skipping insert routes` + ); + return []; + } - const insertRoute = new InsertChange( + const insertRoutes = new InsertChange( sourcePath, outerMostJsxClosing.getStart(), ` -
    + {/* START: routes */} + {/* These routes and navigation have been generated for you */} + {/* Feel free to move and update them to fit your needs */} +
    +
    +
    +
    +
      +
    • Home
    • +
    • Page 2
    • +
    +
    Click here to go back to root page. )} /> + {/* END: routes */} ` ); - findDefaultExport(source); - return [ ...addGlobal( source, sourcePath, - `import { Route, Link} from 'react-router-dom';` + `import { Route, Link } from 'react-router-dom';` ), - insertRoute + insertRoutes ]; } @@ -49,47 +74,91 @@ export function addRoute( sourcePath: string, source: ts.SourceFile, options: { - libName: string; + routePath: string; componentName: string; moduleName: string; - } + }, + context: SchematicContext ): Change[] { - const defaultExport = findDefaultExport(source); + const routes = findElements(source, 'Route'); + const links = findElements(source, 'Link'); - if (!defaultExport) { - throw new Error(`Cannot find default export in ${sourcePath}`); - } - - const elements = findNodes( - defaultExport, - ts.SyntaxKind.JsxSelfClosingElement - ) as ts.JsxSelfClosingElement[]; - - const routes = elements.filter( - x => - x.tagName.kind === ts.SyntaxKind.Identifier && x.tagName.text === 'Route' - ); - - if (routes.length > 0) { - const lastRoute = routes[0]; - - const addImport = addGlobal( - source, - sourcePath, - `import { ${options.componentName} } from '${options.moduleName}';` + if (routes.length === 0) { + context.logger.warn( + `Could not find components in ${sourcePath}; Skipping add route` ); - - const insertRoute = new InsertChange( - sourcePath, - lastRoute.getEnd(), - `` - ); - - return [...addImport, insertRoute]; + return []; } else { - throw new Error(`Could not find routes in ${sourcePath}`); + const changes: Change[] = []; + const firstRoute = routes[0]; + const firstLink = links[0]; + + changes.push( + ...addGlobal( + source, + sourcePath, + `import { ${options.componentName} } from '${options.moduleName}';` + ) + ); + + changes.push( + new InsertChange( + sourcePath, + firstRoute.getEnd(), + `` + ) + ); + + if (firstLink) { + const parentLi = findClosestOpening('li', firstLink); + if (parentLi) { + changes.push( + new InsertChange( + sourcePath, + parentLi.getEnd(), + `
  • ${ + options.componentName + }
  • ` + ) + ); + } else { + changes.push( + new InsertChange( + sourcePath, + firstLink.parent.getEnd(), + `${options.componentName}` + ) + ); + } + } + + return changes; + } +} + +export function addBrowserRouter( + sourcePath: string, + source: ts.SourceFile, + context: SchematicContext +): Change[] { + const app = findElements(source, 'App')[0]; + if (app) { + return [ + ...addGlobal( + source, + sourcePath, + `import { BrowserRouter } from 'react-router-dom';` + ), + new InsertChange(sourcePath, app.getStart(), ``), + new InsertChange(sourcePath, app.getEnd(), ``) + ]; + } else { + context.logger.warn( + `Could not find App component in ${sourcePath}; Skipping add ` + ); + return []; } } @@ -155,6 +224,29 @@ export function findDefaultClassOrFunction( ); } +export function findComponentImportPath(name: string, source: ts.SourceFile) { + const allImports = findNodes( + source, + ts.SyntaxKind.ImportDeclaration + ) as ts.ImportDeclaration[]; + const matching = allImports.filter((i: ts.ImportDeclaration) => { + return ( + i.importClause && + i.importClause.name && + i.importClause.name.getText() === name + ); + }); + + if (matching.length === 0) { + return null; + } + + const appImport = matching[0]; + return appImport.moduleSpecifier.getText().replace(/['"]/g, ''); +} + +// ----------------------------------------------------------------------------- + function hasDefaultExportModifier( x: ts.ClassDeclaration | ts.FunctionDeclaration ) { @@ -163,3 +255,41 @@ function hasDefaultExportModifier( x.modifiers.some(m => m.kind === ts.SyntaxKind.DefaultKeyword) ); } + +function findElements(source: ts.SourceFile, tagName: string) { + const nodes = findNodes(source, [ + ts.SyntaxKind.JsxSelfClosingElement, + ts.SyntaxKind.JsxOpeningElement + ]); + return nodes.filter(node => isTag(tagName, node)); +} + +function findClosestOpening(tagName: string, node: ts.Node) { + if (!node) { + return null; + } + + if (isTag(tagName, node)) { + return node; + } else { + return findClosestOpening(tagName, node.parent); + } +} + +function isTag(tagName: string, node: ts.Node) { + if (ts.isJsxOpeningLikeElement(node)) { + return ( + node.tagName.kind === ts.SyntaxKind.Identifier && + node.tagName.text === tagName + ); + } + + if (ts.isJsxElement(node) && node.openingElement) { + return ( + node.openingElement.tagName.kind === ts.SyntaxKind.Identifier && + node.openingElement.tagName.text === tagName + ); + } + + return false; +} diff --git a/packages/workspace/src/utils/ast-utils.ts b/packages/workspace/src/utils/ast-utils.ts index 56caf3c5f0..7288ad2219 100644 --- a/packages/workspace/src/utils/ast-utils.ts +++ b/packages/workspace/src/utils/ast-utils.ts @@ -45,7 +45,7 @@ function insertAfterLastOccurrence( export function findNodes( node: ts.Node, - kind: ts.SyntaxKind, + kind: ts.SyntaxKind | ts.SyntaxKind[], max = Infinity ): ts.Node[] { if (!node || max == 0) { @@ -53,7 +53,10 @@ export function findNodes( } const arr: ts.Node[] = []; - if (node.kind === kind) { + const hasMatch = Array.isArray(kind) + ? kind.includes(node.kind) + : node.kind === kind; + if (hasMatch) { arr.push(node); max--; }