diff --git a/docs/angular/api-react/schematics/component-cypress-spec.md b/docs/angular/api-react/schematics/component-cypress-spec.md new file mode 100644 index 0000000000..ebc84c3843 --- /dev/null +++ b/docs/angular/api-react/schematics/component-cypress-spec.md @@ -0,0 +1,45 @@ +# component-cypress-spec + +Create a cypress spec for a ui component that has a story + +## Usage + +```bash +nx generate component-cypress-spec ... +``` + +By default, Nx will search for `component-cypress-spec` in the default collection provisioned in `angular.json`. + +You can specify the collection explicitly as follows: + +```bash +nx g @nrwl/react:component-cypress-spec ... +``` + +Show what will be generated without writing to disk: + +```bash +nx g component-cypress-spec ... --dry-run +``` + +## Options + +### componentPath + +Type: `string` + +Relative path to the component file from the library root? + +### js + +Default: `false` + +Type: `boolean` + +Generate JavaScript files rather than TypeScript files + +### project + +Type: `string` + +The project name for which to generate tests diff --git a/docs/angular/api-react/schematics/component-story.md b/docs/angular/api-react/schematics/component-story.md new file mode 100644 index 0000000000..c59eb0d716 --- /dev/null +++ b/docs/angular/api-react/schematics/component-story.md @@ -0,0 +1,37 @@ +# component-story + +Generate storybook story for a react component + +## Usage + +```bash +nx generate component-story ... +``` + +By default, Nx will search for `component-story` in the default collection provisioned in `angular.json`. + +You can specify the collection explicitly as follows: + +```bash +nx g @nrwl/react:component-story ... +``` + +Show what will be generated without writing to disk: + +```bash +nx g component-story ... --dry-run +``` + +## Options + +### componentPath + +Type: `string` + +Relative path to the component file from the library root? + +### project + +Type: `string` + +The project name where to add the components diff --git a/docs/angular/api-react/schematics/stories.md b/docs/angular/api-react/schematics/stories.md new file mode 100644 index 0000000000..d79eed8cfa --- /dev/null +++ b/docs/angular/api-react/schematics/stories.md @@ -0,0 +1,45 @@ +# stories + +Create stories/specs for all components declared in a library + +## Usage + +```bash +nx generate stories ... +``` + +By default, Nx will search for `stories` in the default collection provisioned in `angular.json`. + +You can specify the collection explicitly as follows: + +```bash +nx g @nrwl/react:stories ... +``` + +Show what will be generated without writing to disk: + +```bash +nx g stories ... --dry-run +``` + +## Options + +### generateCypressSpecs + +Type: `boolean` + +Automatically generate \*.spec.ts files in the cypress e2e app generated by the cypress-configure schematic + +### js + +Default: `false` + +Type: `boolean` + +Generate JavaScript files rather than TypeScript files + +### project + +Type: `string` + +The library project name diff --git a/docs/angular/api-react/schematics/storybook-configuration.md b/docs/angular/api-react/schematics/storybook-configuration.md index 238c1faaca..029ceacf29 100644 --- a/docs/angular/api-react/schematics/storybook-configuration.md +++ b/docs/angular/api-react/schematics/storybook-configuration.md @@ -30,6 +30,12 @@ Type: `boolean` Run the cypress-configure schematic +### generateStories + +Type: `boolean` + +Automatically generate \*.stories.ts files for components declared in this library + ### js Default: `false` diff --git a/docs/react/api-react/schematics/component-cypress-spec.md b/docs/react/api-react/schematics/component-cypress-spec.md new file mode 100644 index 0000000000..6ffdeaf227 --- /dev/null +++ b/docs/react/api-react/schematics/component-cypress-spec.md @@ -0,0 +1,45 @@ +# component-cypress-spec + +Create a cypress spec for a ui component that has a story + +## Usage + +```bash +nx generate component-cypress-spec ... +``` + +By default, Nx will search for `component-cypress-spec` in the default collection provisioned in `workspace.json`. + +You can specify the collection explicitly as follows: + +```bash +nx g @nrwl/react:component-cypress-spec ... +``` + +Show what will be generated without writing to disk: + +```bash +nx g component-cypress-spec ... --dry-run +``` + +## Options + +### componentPath + +Type: `string` + +Relative path to the component file from the library root? + +### js + +Default: `false` + +Type: `boolean` + +Generate JavaScript files rather than TypeScript files + +### project + +Type: `string` + +The project name for which to generate tests diff --git a/docs/react/api-react/schematics/component-story.md b/docs/react/api-react/schematics/component-story.md new file mode 100644 index 0000000000..e1265d9ad5 --- /dev/null +++ b/docs/react/api-react/schematics/component-story.md @@ -0,0 +1,37 @@ +# component-story + +Generate storybook story for a react component + +## Usage + +```bash +nx generate component-story ... +``` + +By default, Nx will search for `component-story` in the default collection provisioned in `workspace.json`. + +You can specify the collection explicitly as follows: + +```bash +nx g @nrwl/react:component-story ... +``` + +Show what will be generated without writing to disk: + +```bash +nx g component-story ... --dry-run +``` + +## Options + +### componentPath + +Type: `string` + +Relative path to the component file from the library root? + +### project + +Type: `string` + +The project name where to add the components diff --git a/docs/react/api-react/schematics/stories.md b/docs/react/api-react/schematics/stories.md new file mode 100644 index 0000000000..cbee87c736 --- /dev/null +++ b/docs/react/api-react/schematics/stories.md @@ -0,0 +1,45 @@ +# stories + +Create stories/specs for all components declared in a library + +## Usage + +```bash +nx generate stories ... +``` + +By default, Nx will search for `stories` in the default collection provisioned in `workspace.json`. + +You can specify the collection explicitly as follows: + +```bash +nx g @nrwl/react:stories ... +``` + +Show what will be generated without writing to disk: + +```bash +nx g stories ... --dry-run +``` + +## Options + +### generateCypressSpecs + +Type: `boolean` + +Automatically generate \*.spec.ts files in the cypress e2e app generated by the cypress-configure schematic + +### js + +Default: `false` + +Type: `boolean` + +Generate JavaScript files rather than TypeScript files + +### project + +Type: `string` + +The library project name diff --git a/docs/react/api-react/schematics/storybook-configuration.md b/docs/react/api-react/schematics/storybook-configuration.md index 20b4edfec8..138559ad3c 100644 --- a/docs/react/api-react/schematics/storybook-configuration.md +++ b/docs/react/api-react/schematics/storybook-configuration.md @@ -30,6 +30,12 @@ Type: `boolean` Run the cypress-configure schematic +### generateStories + +Type: `boolean` + +Automatically generate \*.stories.ts files for components declared in this library + ### js Default: `false` diff --git a/packages/angular/src/schematics/component-cypress-spec/component-cypress-spec.ts b/packages/angular/src/schematics/component-cypress-spec/component-cypress-spec.ts index 3431e923d5..afd69f09da 100644 --- a/packages/angular/src/schematics/component-cypress-spec/component-cypress-spec.ts +++ b/packages/angular/src/schematics/component-cypress-spec/component-cypress-spec.ts @@ -16,15 +16,12 @@ import { PropertyDeclaration, SyntaxKind } from 'typescript'; -import { - getTsSourceFile, - getDecoratorMetadata, - applyWithSkipExisting -} from '../../utils/ast-utils'; +import { getTsSourceFile, getDecoratorMetadata } from '../../utils/ast-utils'; import { getInputPropertyDeclarations, getKnobType } from '../component-story/component-story'; +import { applyWithSkipExisting } from '@nrwl/workspace/src/utils/ast-utils'; export default function(schema: CreateComponentSpecFileSchema): Rule { return chain([createComponentSpecFile(schema)]); diff --git a/packages/angular/src/schematics/component-story/component-story.ts b/packages/angular/src/schematics/component-story/component-story.ts index ed3aef2470..c548827319 100644 --- a/packages/angular/src/schematics/component-story/component-story.ts +++ b/packages/angular/src/schematics/component-story/component-story.ts @@ -9,8 +9,11 @@ import { } from '@angular-devkit/schematics'; import { findNodes } from '@nrwl/workspace'; import { PropertyDeclaration, SyntaxKind } from 'typescript'; -import { getTsSourceFile, applyWithSkipExisting } from '../../utils/ast-utils'; -import { getSourceNodes } from '@nrwl/workspace/src/utils/ast-utils'; +import { getTsSourceFile } from '../../utils/ast-utils'; +import { + getSourceNodes, + applyWithSkipExisting +} from '@nrwl/workspace/src/utils/ast-utils'; export interface CreateComponentStoriesFileSchema { libPath: string; diff --git a/packages/angular/src/utils/ast-utils.ts b/packages/angular/src/utils/ast-utils.ts index 84115384b0..06415eb4d4 100644 --- a/packages/angular/src/utils/ast-utils.ts +++ b/packages/angular/src/utils/ast-utils.ts @@ -641,21 +641,3 @@ export function getTsSourceFile(host: Tree, path: string): ts.SourceFile { return source; } - -export function applyWithSkipExisting(source: Source, rules: Rule[]): Rule { - return (tree: Tree, _context: SchematicContext) => { - const rule = mergeWith( - apply(source, [ - ...rules, - forEach(fileEntry => { - if (tree.exists(fileEntry.path)) { - return null; - } - return fileEntry; - }) - ]) - ); - - return rule(tree, _context); - }; -} diff --git a/packages/cypress/src/schematics/cypress-project/cypress-project.ts b/packages/cypress/src/schematics/cypress-project/cypress-project.ts index 0278052b1e..cb46b47cb3 100644 --- a/packages/cypress/src/schematics/cypress-project/cypress-project.ts +++ b/packages/cypress/src/schematics/cypress-project/cypress-project.ts @@ -92,7 +92,23 @@ function updateWorkspaceJson(options: CypressProjectSchema): Rule { export default function(options: CypressProjectSchema): Rule { options = normalizeOptions(options); return chain([ - addLintFiles(options.projectRoot, options.linter), + addLintFiles(options.projectRoot, options.linter, { + localConfig: { + // we need this overrides because we enabled + // allowJS in the tsconfig to allow for JS based + // Cypress tests. That however leads to issues + // with the CommonJS Cypress plugin file + overrides: [ + { + files: ['src/plugins/index.js'], + rules: { + '@typescript-eslint/no-var-requires': 'off', + 'no-undef': 'off' + } + } + ] + } + }), generateFiles(options), updateWorkspaceJson(options), updateNxJson(options) diff --git a/packages/cypress/src/schematics/cypress-project/files/tsconfig.e2e.json b/packages/cypress/src/schematics/cypress-project/files/tsconfig.e2e.json index ff763c0b2d..dd29cb01e0 100644 --- a/packages/cypress/src/schematics/cypress-project/files/tsconfig.e2e.json +++ b/packages/cypress/src/schematics/cypress-project/files/tsconfig.e2e.json @@ -2,7 +2,8 @@ "extends": "./tsconfig.json", "compilerOptions": { "sourceMap": false, - "outDir": "<%= offsetFromRoot %>dist/out-tsc" + "outDir": "<%= offsetFromRoot %>dist/out-tsc", + "allowJs": true }, "include": ["src/**/*.ts", "src/**/*.js"] } diff --git a/packages/react/collection.json b/packages/react/collection.json index 500d2de21d..859d88b7ad 100644 --- a/packages/react/collection.json +++ b/packages/react/collection.json @@ -44,6 +44,27 @@ "schema": "./src/schematics/storybook-configuration/schema.json", "description": "Set up storybook for a react library", "hidden": false + }, + + "component-story": { + "factory": "./src/schematics/component-story/component-story", + "schema": "./src/schematics/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", + "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", + "description": "Create a cypress spec for a ui component that has a story", + "hidden": false } } } diff --git a/packages/react/src/schematics/component-cypress-spec/component-cypress-spec.spec.ts b/packages/react/src/schematics/component-cypress-spec/component-cypress-spec.spec.ts new file mode 100644 index 0000000000..183d228d12 --- /dev/null +++ b/packages/react/src/schematics/component-cypress-spec/component-cypress-spec.spec.ts @@ -0,0 +1,178 @@ +import { externalSchematic, Tree } from '@angular-devkit/schematics'; +import { UnitTestTree } from '@angular-devkit/schematics/testing'; +import { createEmptyWorkspace } from '@nrwl/workspace/testing'; +import { callRule, runSchematic } from '../../utils/testing'; +import { CreateComponentSpecFileSchema } from './component-cypress-spec'; +import { stripIndents } from '@angular-devkit/core/src/utils/literals'; + +describe('react:component-cypress-spec', () => { + let appTree: Tree; + let tree: UnitTestTree; + + [ + { + plainJS: false, + testCmpSrcWithProps: `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; + `, + testCmpSrcWithoutProps: `import React from 'react'; + + import './test.scss'; + + export const Test = () => { + return ( +
+

Welcome to test component

+
+ ); + }; + + export default Test; + ` + }, + { + plainJS: true, + testCmpSrcWithProps: `import React from 'react'; + + import './test.scss'; + export const Test = (props: TestProps) => { + return ( +
+

Welcome to test component, {props.name}

+
+ ); + }; + + export default Test; + `, + testCmpSrcWithoutProps: `import React from 'react'; + import './test.scss'; + export const Test = () => { + return ( +
+

Welcome to test component

+
+ ); + }; + + export default Test; + ` + } + ].forEach(testConfig => { + let fileCmpExt = testConfig.plainJS ? 'js' : 'tsx'; + let fileExt = testConfig.plainJS ? 'js' : 'ts'; + + describe(`using ${ + testConfig.plainJS ? 'plain JS' : 'TypeScript' + } setup`, () => { + let cmpPath = `libs/test-ui-lib/src/lib/test-ui-lib.${fileCmpExt}`; + let cypressStorySpecFilePath = `apps/test-ui-lib-e2e/src/integration/test-ui-lib/test-ui-lib.spec.${fileExt}`; + + if (!testConfig.plainJS) { + // hacky, but we should do this check only if we run with TypeScript, + // detecting component props in plain JS is "not possible" + describe('component with properties', () => { + beforeEach(async () => { + appTree = await createTestUILib('test-ui-lib', testConfig.plainJS); + + appTree.overwrite(cmpPath, testConfig.testCmpSrcWithProps); + + tree = await runSchematic( + 'component-cypress-spec', + { + componentPath: `lib/test-ui-lib.${fileCmpExt}`, + project: 'test-ui-lib', + js: testConfig.plainJS + }, + appTree + ); + }); + + it('should properly set up the spec', () => { + expect(stripIndents`${tree.readContent(cypressStorySpecFilePath)}`) + .toContain(stripIndents`describe('test-ui-lib: Test component', () => { + beforeEach(() => cy.visit('/iframe.html?id=test--primary&knob-name=&knob-displayAge=false')); + + it('should render the component', () => { + cy.get('h1').should('contain', 'Welcome to test-ui-lib!'); + }); + }); + `); + }); + }); + } + + describe('component without properties', () => { + beforeEach(async () => { + appTree = await createTestUILib('test-ui-lib', testConfig.plainJS); + + appTree.overwrite(cmpPath, testConfig.testCmpSrcWithoutProps); + + tree = await runSchematic( + 'component-cypress-spec', + { + componentPath: `lib/test-ui-lib.${fileCmpExt}`, + project: 'test-ui-lib', + js: testConfig.plainJS + }, + appTree + ); + }); + + it('should properly set up the spec', () => { + expect(stripIndents`${tree.readContent(cypressStorySpecFilePath)}`) + .toContain(stripIndents`describe('test-ui-lib: Test component', () => { + beforeEach(() => cy.visit('/iframe.html?id=test--primary')); + + it('should render the component', () => { + cy.get('h1').should('contain', 'Welcome to test-ui-lib!'); + }); + }); + `); + }); + }); + }); + }); +}); + +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, + js: plainJS + }), + appTree + ); + + // 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 + }), + appTree + ); + return appTree; +} diff --git a/packages/react/src/schematics/component-cypress-spec/component-cypress-spec.ts b/packages/react/src/schematics/component-cypress-spec/component-cypress-spec.ts new file mode 100644 index 0000000000..d735cfe14e --- /dev/null +++ b/packages/react/src/schematics/component-cypress-spec/component-cypress-spec.ts @@ -0,0 +1,110 @@ +import { normalize, schema } from '@angular-devkit/core'; +import { + chain, + move, + Rule, + SchematicContext, + SchematicsException, + applyTemplates, + Tree, + url +} from '@angular-devkit/schematics'; +import { findNodes, getProjectConfig } from '@nrwl/workspace'; +import { applyWithSkipExisting } from '@nrwl/workspace/src/utils/ast-utils'; +import { join } from 'path'; +import ts = require('typescript'); +import { + getComponentName, + getComponentPropsInterface +} from '../../utils/ast-utils'; + +export interface CreateComponentSpecFileSchema { + project: string; + componentPath: string; + js?: boolean; +} + +export default function(schema: CreateComponentSpecFileSchema): Rule { + return chain([createComponentSpecFile(schema)]); +} + +// TODO: candidate to refactor with the angular component story +export function getKnobDefaultValue(property: ts.SyntaxKind): string { + const typeNameToDefault: Record = { + [ts.SyntaxKind.StringKeyword]: '', + [ts.SyntaxKind.NumberKeyword]: 0, + [ts.SyntaxKind.BooleanKeyword]: false + }; + + const resolvedValue = typeNameToDefault[property]; + if (typeof resolvedValue === undefined) { + return ''; + } else { + return resolvedValue; + } +} + +export function createComponentSpecFile({ + project, + componentPath, + js +}: CreateComponentSpecFileSchema): Rule { + return (tree: Tree, context: SchematicContext): Rule => { + const e2eLibIntegrationFolderPath = + getProjectConfig(tree, project + '-e2e').sourceRoot + '/integration'; + + const proj = getProjectConfig(tree, project); + const componentFilePath = normalize(join(proj.sourceRoot, componentPath)); + const componentName = componentFilePath + .slice(componentFilePath.lastIndexOf('/') + 1) + .replace('.tsx', '') + .replace('.jsx', '') + .replace('.js', ''); + + const contents = tree.read(componentFilePath); + if (!contents) { + throw new SchematicsException(`Failed to read ${componentFilePath}`); + } + + const sourceFile = ts.createSourceFile( + componentFilePath, + contents.toString(), + ts.ScriptTarget.Latest, + true + ); + + const cmpDeclaration = getComponentName(sourceFile); + if (!cmpDeclaration) { + throw new SchematicsException( + `Could not find any React component in file ${componentFilePath}` + ); + } + + const propsInterface = getComponentPropsInterface(sourceFile); + + let props: { + name: string; + defaultValue: any; + }[] = []; + + if (propsInterface) { + props = propsInterface.members.map((member: ts.PropertySignature) => { + return { + name: (member.name as ts.Identifier).text, + defaultValue: getKnobDefaultValue(member.type.kind) + }; + }); + } + + return applyWithSkipExisting(url('./files'), [ + applyTemplates({ + projectName: project, + componentName, + componentSelector: (cmpDeclaration as any).name.text, + props, + fileExt: js ? 'js' : 'ts' + }), + move(e2eLibIntegrationFolderPath + '/' + componentName) + ]); + }; +} diff --git a/packages/react/src/schematics/component-cypress-spec/files/__componentName__.spec.__fileExt__.template b/packages/react/src/schematics/component-cypress-spec/files/__componentName__.spec.__fileExt__.template new file mode 100644 index 0000000000..9a14b836db --- /dev/null +++ b/packages/react/src/schematics/component-cypress-spec/files/__componentName__.spec.__fileExt__.template @@ -0,0 +1,12 @@ +describe('<%=projectName%>: <%= componentSelector %> component', () => { + beforeEach(() => cy.visit('/iframe.html?id=<%= componentSelector.toLowerCase() %>--primary<% + for(let prop of props) { + %>&knob-<%=prop.name%><% + if(prop.defaultValue !== undefined) { + %>=<%=prop.defaultValue%><% + } %><%}%>')); + + it('should render the component', () => { + cy.get('h1').should('contain', 'Welcome to <%=projectName%>!'); + }); +}); diff --git a/packages/react/src/schematics/component-cypress-spec/schema.json b/packages/react/src/schematics/component-cypress-spec/schema.json new file mode 100644 index 0000000000..0a8bafa379 --- /dev/null +++ b/packages/react/src/schematics/component-cypress-spec/schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "component-cypress-spec", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project name for which to generate tests", + "examples": ["shared-ui-component"], + "$default": { + "$source": "projectName", + "index": 0 + }, + "x-prompt": "What's name of the project for which to generate tests?" + }, + "componentPath": { + "type": "string", + "description": "Relative path to the component file from the library root?", + "examples": ["lib/components"], + "x-prompt": "What's path of the component relative to the project's lib root for which to generate a test?" + }, + "js": { + "type": "boolean", + "description": "Generate JavaScript files rather than TypeScript files", + "default": false + } + }, + "required": ["project", "componentPath"] +} diff --git a/packages/react/src/schematics/component-story/component-story.spec.ts b/packages/react/src/schematics/component-story/component-story.spec.ts new file mode 100644 index 0000000000..5c1574701d --- /dev/null +++ b/packages/react/src/schematics/component-story/component-story.spec.ts @@ -0,0 +1,430 @@ +import { externalSchematic, Tree } from '@angular-devkit/schematics'; +import { UnitTestTree } from '@angular-devkit/schematics/testing'; +import { createEmptyWorkspace } from '@nrwl/workspace/testing'; +import { callRule, runSchematic } from '../../utils/testing'; +import { CreateComponentStoriesFileSchema } from './component-story'; +import { stripIndents } from '@angular-devkit/core/src/utils/literals'; +import { updateWorkspaceInTree } from '@nrwl/workspace'; + +describe('react:component-story', () => { + let appTree: Tree; + let tree: UnitTestTree; + let cmpPath = 'libs/test-ui-lib/src/lib/test-ui-lib.tsx'; + let storyFilePath = 'libs/test-ui-lib/src/lib/test-ui-lib.stories.tsx'; + + describe('default setup', () => { + beforeEach(async () => { + appTree = await createTestUILib('test-ui-lib', true); + }); + + describe('when file does not contain a component', () => { + beforeEach(() => { + appTree.overwrite( + cmpPath, + `export const add = (a: number, b: number) => a + b;` + ); + }); + + it('should fail with a descriptive error message', async done => { + try { + tree = await runSchematic( + 'component-story', + { + componentPath: 'lib/test-ui-lib.tsx', + project: 'test-ui-lib' + }, + appTree + ); + } catch (e) { + expect(e.message).toContain( + 'Could not find any React component in file libs/test-ui-lib/src/lib/test-ui-lib.tsx' + ); + done(); + } + }); + }); + + describe('default component setup', () => { + beforeEach(async () => { + tree = await runSchematic( + 'component-story', + { + componentPath: 'lib/test-ui-lib.tsx', + project: 'test-ui-lib' + }, + appTree + ); + }); + + it('should create the story file', () => { + expect(tree.exists(storyFilePath)).toBeTruthy(); + }); + + it('should properly set up the story', () => { + expect(tree.readContent(storyFilePath)) + .toContain(`import React from 'react'; +import { TestUiLib, TestUiLibProps } from './test-ui-lib'; + +export default { + component: TestUiLib, + title: 'TestUiLib' +}; + +export const primary = () => { + /* eslint-disable-next-line */ + const props: TestUiLibProps = {}; + + return ; +};`); + }); + }); + + describe('when using plain JS components', () => { + let storyFilePathPlain = + 'libs/test-ui-lib/src/lib/test-ui-libplain.stories.jsx'; + + beforeEach(async () => { + appTree.create( + 'libs/test-ui-lib/src/lib/test-ui-libplain.jsx', + `import React from 'react'; + + import './test.scss'; + + export const Test = () => { + return ( +
+

Welcome to test component

+
+ ); + }; + + export default Test; + ` + ); + + tree = await runSchematic( + 'component-story', + { + componentPath: 'lib/test-ui-libplain.jsx', + project: 'test-ui-lib' + }, + appTree + ); + }); + + it('should create the story file', () => { + expect(tree.exists(storyFilePathPlain)).toBeTruthy(); + }); + + it('should properly set up the story', () => { + expect(tree.readContent(storyFilePathPlain)) + .toContain(`import React from 'react'; +import { Test } from './test-ui-libplain'; + +export default { + component: Test, + title: 'Test' +}; + +export const primary = () => { + /* eslint-disable-next-line */ + const props = {}; + + return ; +};`); + }); + }); + + describe('component without any props defined', () => { + beforeEach(async () => { + appTree.overwrite( + cmpPath, + `import React from 'react'; + + import './test.scss'; + + export const Test = () => { + return ( +
+

Welcome to test component

+
+ ); + }; + + export default Test; + ` + ); + + tree = await runSchematic( + 'component-story', + { + componentPath: 'lib/test-ui-lib.tsx', + project: 'test-ui-lib' + }, + appTree + ); + }); + + it('should create a story without knobs', () => { + expect(tree.readContent(storyFilePath)) + .toContain(`import React from 'react'; +import { Test } from './test-ui-lib'; + +export default { + component: Test, + title: 'Test' +}; + +export const primary = () => { + return ; +}; +`); + }); + }); + + describe('component with props', () => { + beforeEach(async () => { + appTree.overwrite( + cmpPath, + `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; + ` + ); + + tree = await runSchematic( + 'component-story', + { + componentPath: 'lib/test-ui-lib.tsx', + project: 'test-ui-lib' + }, + appTree + ); + }); + + it('should setup knobs based on the component props', () => { + expect(tree.readContent(storyFilePath)) + .toContain(`import { text, boolean } from '@storybook/addon-knobs'; +import React from 'react'; +import { Test, TestProps } from './test-ui-lib'; + +export default { + component: Test, + title: 'Test' +}; + +export const primary = () => { + const props: TestProps = { + name: text('name', ''), + displayAge: boolean('displayAge', false) + }; + + return ; +}; +`); + }); + }); + + [ + { + name: 'default export function', + src: `export default function Test(props: TestProps) { + return ( +
+

Welcome to test component, {props.name}

+
+ ); + }; + ` + }, + { + name: 'function and then export', + src: ` + function Test(props: TestProps) { + return ( +
+

Welcome to test component, {props.name}

+
+ ); + }; + export default Test; + ` + }, + { + name: 'arrow function', + src: ` + const Test = (props: TestProps) => { + return ( +
+

Welcome to test component, {props.name}

+
+ ); + }; + export default Test; + ` + }, + { + name: 'arrow function without {..}', + src: ` + const Test = (props: TestProps) =>

Welcome to test component, {props.name}

; + export default Test + ` + }, + { + name: 'direct export of component class', + src: ` + export default class Test extends React.Component { + render() { + return

Welcome to test component, {this.props.name}

; + } + } + ` + }, + { + name: 'component class & then default export', + src: ` + class Test extends React.Component { + render() { + return

Welcome to test component, {this.props.name}

; + } + } + export default Test + ` + }, + { + name: 'PureComponent class & then default export', + src: ` + class Test extends React.PureComponent { + render() { + return

Welcome to test component, {this.props.name}

; + } + } + export default Test + ` + } + ].forEach(config => { + describe(`React component defined as:${config.name}`, () => { + beforeEach(async () => { + appTree.overwrite( + cmpPath, + `import React from 'react'; + + import './test.scss'; + + export interface TestProps { + name: string; + displayAge: boolean; + } + + ${config.src} + ` + ); + + tree = await runSchematic( + 'component-story', + { + componentPath: 'lib/test-ui-lib.tsx', + project: 'test-ui-lib' + }, + appTree + ); + }); + + it('should properly setup the knobs based on the component props', () => { + expect(tree.readContent(storyFilePath)) + .toContain(`import { text, boolean } from '@storybook/addon-knobs'; +import React from 'react'; +import { Test, TestProps } from './test-ui-lib'; + +export default { + component: Test, + title: 'Test' +}; + +export const primary = () => { + const props: TestProps = { + name: text('name', ''), + displayAge: boolean('displayAge', false) + }; + + return ; +};`); + }); + }); + }); + }); + + describe('using tslint', () => { + beforeEach(async () => { + appTree = await createTestUILib('test-ui-lib', false); + tree = await runSchematic( + 'component-story', + { + componentPath: 'lib/test-ui-lib.tsx', + project: 'test-ui-lib' + }, + appTree + ); + }); + + it('should properly set up the story', () => { + expect(tree.readContent(storyFilePath)) + .toContain(`import React from 'react'; +import { TestUiLib, TestUiLibProps } from './test-ui-lib'; + +export default { + component: TestUiLib, + title: 'TestUiLib' +}; + +export const primary = () => { + /* tslint-disable-next-line */ + const props: TestUiLibProps = {}; + + return ; +};`); + }); + }); +}); + +export async function createTestUILib( + libName: string, + useEsLint = false +): Promise { + let appTree = Tree.empty(); + appTree = createEmptyWorkspace(appTree); + appTree = await callRule( + externalSchematic('@nrwl/react', 'library', { + name: libName + }), + appTree + ); + + if (useEsLint) { + const currentWorkspaceJson = JSON.parse( + appTree.read('workspace.json').toString('utf-8') + ); + + currentWorkspaceJson.projects[libName].architect.lint.options.linter = + 'eslint'; + + appTree.overwrite('workspace.json', JSON.stringify(currentWorkspaceJson)); + } + + return appTree; +} diff --git a/packages/react/src/schematics/component-story/component-story.ts b/packages/react/src/schematics/component-story/component-story.ts new file mode 100644 index 0000000000..b0510e3f45 --- /dev/null +++ b/packages/react/src/schematics/component-story/component-story.ts @@ -0,0 +1,148 @@ +import { + Rule, + chain, + SchematicContext, + Tree, + template, + move, + url, + SchematicsException, + applyTemplates +} from '@angular-devkit/schematics'; +import { normalize } from '@angular-devkit/core'; +import { getProjectConfig, formatFiles } from '@nrwl/workspace'; +import { join } from 'path'; +import { + applyWithSkipExisting, + findNodes +} from '@nrwl/workspace/src/utils/ast-utils'; +import * as ts from 'typescript'; +import { + findDefaultExport, + getComponentName, + getComponentPropsInterface +} from '../../utils/ast-utils'; + +export interface CreateComponentStoriesFileSchema { + project: string; + componentPath: string; +} + +export type KnobType = 'text' | 'boolean' | 'number' | 'select'; + +// TODO: candidate to refactor with the angular component story +export function getKnobDefaultValue(property: ts.SyntaxKind): string { + const typeNameToDefault: Record = { + [ts.SyntaxKind.StringKeyword]: "''", + [ts.SyntaxKind.NumberKeyword]: 0, + [ts.SyntaxKind.BooleanKeyword]: false + }; + + const resolvedValue = typeNameToDefault[property]; + if (typeof resolvedValue === undefined) { + return "''"; + } else { + return resolvedValue; + } +} + +export function createComponentStoriesFile({ + // name, + project, + componentPath +}: CreateComponentStoriesFileSchema): Rule { + return (tree: Tree, context: SchematicContext): Rule => { + const proj = getProjectConfig(tree, project); + const sourceRoot = proj.sourceRoot; + const usesEsLint = proj.architect.lint.options.linter === 'eslint'; + + const componentFilePath = normalize(join(sourceRoot, componentPath)); + const componentDirectory = componentFilePath.replace( + componentFilePath.slice(componentFilePath.lastIndexOf('/')), + '' + ); + + const isPlainJs = componentFilePath.endsWith('.jsx'); + let fileExt = 'tsx'; + if (componentFilePath.endsWith('.jsx')) { + fileExt = 'jsx'; + } else if (componentFilePath.endsWith('.js')) { + fileExt = 'js'; + } + + const componentFileName = componentFilePath + .slice(componentFilePath.lastIndexOf('/') + 1) + .replace('.tsx', '') + .replace('.jsx', '') + .replace('.js', ''); + + const name = componentFileName; + + const contents = tree.read(componentFilePath); + if (!contents) { + throw new SchematicsException(`Failed to read ${componentFilePath}`); + } + + const sourceFile = ts.createSourceFile( + componentFilePath, + contents.toString(), + ts.ScriptTarget.Latest, + true + ); + + const cmpDeclaration = getComponentName(sourceFile); + + if (!cmpDeclaration) { + throw new SchematicsException( + `Could not find any React component in file ${componentFilePath}` + ); + } + + const propsInterface = getComponentPropsInterface(sourceFile); + + let propsTypeName: string = null; + let props: { + name: string; + type: KnobType; + defaultValue: any; + }[] = []; + + if (propsInterface) { + propsTypeName = propsInterface.name.text; + + props = propsInterface.members.map((member: ts.PropertySignature) => { + const initializerKindToKnobType: Record = { + [ts.SyntaxKind.StringKeyword]: 'text', + [ts.SyntaxKind.NumberKeyword]: 'number', + [ts.SyntaxKind.BooleanKeyword]: 'boolean' + }; + + return { + name: (member.name as ts.Identifier).text, + type: initializerKindToKnobType[member.type.kind], + defaultValue: getKnobDefaultValue(member.type.kind) + }; + }); + } + + return chain([ + applyWithSkipExisting(url('./files'), [ + applyTemplates({ + componentFileName: name, + propsTypeName, + props, + usedKnobs: props.map(x => x.type).join(', '), + componentName: (cmpDeclaration as any).name.text, + isPlainJs, + fileExt, + usesEsLint + }), + move(normalize(componentDirectory)) + ]) + ]); + }; +} + +export default function(schema: CreateComponentStoriesFileSchema): Rule { + return chain([createComponentStoriesFile(schema), formatFiles()]); +} diff --git a/packages/react/src/schematics/component-story/files/__componentFileName__.stories.__fileExt__.template b/packages/react/src/schematics/component-story/files/__componentFileName__.stories.__fileExt__.template new file mode 100644 index 0000000000..26395fb12e --- /dev/null +++ b/packages/react/src/schematics/component-story/files/__componentFileName__.stories.__fileExt__.template @@ -0,0 +1,19 @@ +<% if(usedKnobs && usedKnobs.length > 0) { %>import { <%= usedKnobs %> } from '@storybook/addon-knobs';<% } %> +import React from 'react'; +import { <%= componentName %><% if ( propsTypeName && !isPlainJs ) { %>, <%= propsTypeName %> <% } %> } from './<%= componentFileName %>'; + +export default { + component: <%= componentName %>, + title: '<%= componentName %>' +}; + +export const primary = () => { + <% if (propsTypeName || isPlainJs ) { %> + <% if (props.length === 0) { %>/* <%= usesEsLint ? 'eslint' : 'tslint'%>-disable-next-line */<% } %> + const props<%= isPlainJs ? '': ':' + propsTypeName %> = {<% for (let prop of props) { %> + <%= prop.name %>: <%= prop.type %>('<%= prop.name %>', <%= prop.defaultValue %>),<% } %> + }; + <% } %> + + return <<%= componentName %> <% for (let prop of props) { %><%= prop.name %> = {props.<%= prop.name %>} <% } %> />; +}; diff --git a/packages/react/src/schematics/component-story/schema.json b/packages/react/src/schematics/component-story/schema.json new file mode 100644 index 0000000000..0f5aac6c5e --- /dev/null +++ b/packages/react/src/schematics/component-story/schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "component-story", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project name where to add the components", + "examples": ["shared-ui-component"], + "$default": { + "$source": "projectName", + "index": 0 + }, + "x-prompt": "What's name of the project where the component lives?" + }, + "componentPath": { + "type": "string", + "description": "Relative path to the component file from the library root?", + "examples": ["lib/components"], + "x-prompt": "What's path of the component relative to the project's lib root" + } + }, + "required": ["project", "componentPath"] +} diff --git a/packages/react/src/schematics/stories/schema.json b/packages/react/src/schematics/stories/schema.json new file mode 100644 index 0000000000..7364f7ddd4 --- /dev/null +++ b/packages/react/src/schematics/stories/schema.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "storybook-stories", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The library project name", + "$default": { + "$source": "projectName", + "index": 0 + }, + "x-prompt": "What's name of the project for which you want to generate stories?" + }, + "generateCypressSpecs": { + "type": "boolean", + "description": "Automatically generate *.spec.ts files in the cypress e2e app generated by the cypress-configure schematic", + "x-prompt": "Do you want to generate Cypress specs as well?" + }, + "js": { + "type": "boolean", + "description": "Generate JavaScript files rather than TypeScript files", + "default": false + } + }, + "required": ["project"] +} diff --git a/packages/react/src/schematics/stories/stories.spec.ts b/packages/react/src/schematics/stories/stories.spec.ts new file mode 100644 index 0000000000..967be818c9 --- /dev/null +++ b/packages/react/src/schematics/stories/stories.spec.ts @@ -0,0 +1,127 @@ +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'; + +describe('react:stories', () => { + let appTree: Tree; + let tree: UnitTestTree; + + beforeEach(async () => { + appTree = await createTestUILib('test-ui-lib'); + + // create another component + appTree.create( + 'libs/test-ui-lib/src/lib/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 () => { + tree = await runSchematic( + 'stories', + { + project: 'test-ui-lib' + }, + appTree + ); + + expect( + tree.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') + ).toBeTruthy(); + }); + + it('should generate Cypress specs', async () => { + tree = await runSchematic( + 'stories', + { + project: 'test-ui-lib', + generateCypressSpecs: true + }, + appTree + ); + + expect( + tree.exists( + 'apps/test-ui-lib-e2e/src/integration/test-ui-lib/test-ui-lib.spec.ts' + ) + ).toBeTruthy(); + expect( + tree.exists( + 'apps/test-ui-lib-e2e/src/integration/another-cmp/another-cmp.spec.ts' + ) + ).toBeTruthy(); + }); + + xit('should not overwrite existing stories', () => {}); + + it('should ignore files that do not contain components', async () => { + // create another component + appTree.create( + '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 + ); + + // 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') + ).toBeTruthy(); + }); +}); + +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 + ); + + // 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 + ); + return appTree; +} diff --git a/packages/react/src/schematics/stories/stories.ts b/packages/react/src/schematics/stories/stories.ts new file mode 100644 index 0000000000..5dd5def2a1 --- /dev/null +++ b/packages/react/src/schematics/stories/stories.ts @@ -0,0 +1,98 @@ +import { + chain, + Rule, + schematic, + SchematicContext, + Tree, + SchematicsException, + noop +} from '@angular-devkit/schematics'; +import { getProjectConfig } from '@nrwl/workspace'; +import { join } from 'path'; +import { CreateComponentStoriesFileSchema } from '../component-story/component-story'; +import { CreateComponentSpecFileSchema } from '../component-cypress-spec/component-cypress-spec'; +import { getComponentName } from '../../utils/ast-utils'; +import * as ts from 'typescript'; + +export interface StorybookStoriesSchema { + project: string; + generateCypressSpecs: boolean; + js?: boolean; +} + +function containsComponentDeclaration( + tree: Tree, + componentPath: string +): boolean { + const contents = tree.read(componentPath); + if (!contents) { + throw new SchematicsException(`Failed to read ${componentPath}`); + } + + const sourceFile = ts.createSourceFile( + componentPath, + contents.toString(), + ts.ScriptTarget.Latest, + true + ); + + return !!getComponentName(sourceFile); +} + +export function createAllStories( + projectName: string, + generateCypressSpecs: boolean, + js +): Rule { + return (tree: Tree, context: SchematicContext) => { + const projectSrcRoot = getProjectConfig(tree, projectName).sourceRoot; + const libPath = join(projectSrcRoot, '/lib'); + + let componentPaths: string[] = []; + tree.getDir(libPath).visit(filePath => { + 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); + } + }); + + return chain( + componentPaths.map(componentPath => { + const relativeCmpDir = componentPath.replace( + join('/', projectSrcRoot, '/'), + '' + ); + + if (!containsComponentDeclaration(tree, componentPath)) { + return chain([noop()]); + } + + return chain([ + schematic('component-story', { + componentPath: relativeCmpDir, + project: projectName + }), + generateCypressSpecs + ? schematic( + 'component-cypress-spec', + { + project: projectName, + componentPath: relativeCmpDir, + js + } + ) + : () => {} + ]); + }) + ); + }; +} + +export default function(schema: StorybookStoriesSchema): Rule { + return chain([ + createAllStories(schema.project, schema.generateCypressSpecs, schema.js) + ]); +} diff --git a/packages/react/src/schematics/storybook-configuration/configuration.spec.ts b/packages/react/src/schematics/storybook-configuration/configuration.spec.ts index 374ca328e0..545f351ddf 100644 --- a/packages/react/src/schematics/storybook-configuration/configuration.spec.ts +++ b/packages/react/src/schematics/storybook-configuration/configuration.spec.ts @@ -1,11 +1,18 @@ -import { Tree, schematic, externalSchematic } from '@angular-devkit/schematics'; -import { runSchematic, callRule } from '../../utils/testing'; -import { StorybookConfigureSchema } from './schema'; +import { externalSchematic, Tree } from '@angular-devkit/schematics'; import { createEmptyWorkspace } from '@nrwl/workspace/testing'; +import { callRule, runSchematic } from '../../utils/testing'; +import { StorybookConfigureSchema } from './schema'; describe('react:storybook-configuration', () => { + let appTree; + + // beforeEach(async () => { + // appTree = await createTestUILib('test-ui-lib'); + // }); + it('should configure everything at once', async () => { - const appTree = await createTestUILib('test-ui-lib'); + appTree = await createTestUILib('test-ui-lib'); + const tree = await runSchematic( 'storybook-configuration', { @@ -21,14 +28,72 @@ describe('react:storybook-configuration', () => { ).toBeTruthy(); expect(tree.exists('apps/test-ui-lib-e2e/cypress.json')).toBeTruthy(); }); + + it('should generate stories for components', async () => { + appTree = await createTestUILib('test-ui-lib'); + + const tree = await runSchematic( + 'storybook-configuration', + { + name: 'test-ui-lib', + generateStories: true + }, + appTree + ); + + expect( + tree.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.create( + '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; + ` + ); + + const tree = await runSchematic( + 'storybook-configuration', + { + name: 'test-ui-lib', + generateCypressSpecs: true, + generateStories: true, + js: true + }, + appTree + ); + + expect( + tree.exists('libs/test-ui-lib/src/lib/test-ui-libplain.stories.js') + ).toBeTruthy(); + }); }); -export async function createTestUILib(libName: string): Promise { +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 + name: libName, + js: plainJS }), appTree ); diff --git a/packages/react/src/schematics/storybook-configuration/configuration.ts b/packages/react/src/schematics/storybook-configuration/configuration.ts index 645fc12871..7c15f90e2b 100644 --- a/packages/react/src/schematics/storybook-configuration/configuration.ts +++ b/packages/react/src/schematics/storybook-configuration/configuration.ts @@ -1,5 +1,23 @@ -import { chain, externalSchematic, Rule } from '@angular-devkit/schematics'; +import { + chain, + externalSchematic, + Rule, + schematic, + noop +} from '@angular-devkit/schematics'; import { StorybookConfigureSchema } from './schema'; +import { StorybookStoriesSchema } from '../stories/stories'; + +function generateStories(schema: StorybookConfigureSchema): Rule { + return (tree, context) => { + return schematic('stories', { + project: schema.name, + generateCypressSpecs: + schema.configureCypress && schema.generateCypressSpecs, + js: schema.js + }); + }; +} export default function(schema: StorybookConfigureSchema): Rule { return chain([ @@ -8,6 +26,7 @@ export default function(schema: StorybookConfigureSchema): Rule { uiFramework: '@storybook/react', configureCypress: schema.configureCypress, js: schema.js - }) + }), + schema.generateStories ? generateStories(schema) : noop() ]); } diff --git a/packages/react/src/schematics/storybook-configuration/schema.d.ts b/packages/react/src/schematics/storybook-configuration/schema.d.ts index 6637784d8b..0a850f838d 100644 --- a/packages/react/src/schematics/storybook-configuration/schema.d.ts +++ b/packages/react/src/schematics/storybook-configuration/schema.d.ts @@ -1,5 +1,7 @@ export interface StorybookConfigureSchema { name: string; configureCypress: boolean; + generateStories?: boolean; + generateCypressSpecs?: boolean; js?: boolean; } diff --git a/packages/react/src/schematics/storybook-configuration/schema.json b/packages/react/src/schematics/storybook-configuration/schema.json index 985e760820..a38b72f1a0 100644 --- a/packages/react/src/schematics/storybook-configuration/schema.json +++ b/packages/react/src/schematics/storybook-configuration/schema.json @@ -16,6 +16,11 @@ "description": "Run the cypress-configure schematic", "x-prompt": "Configure a cypress e2e app to run against the storybook instance?" }, + "generateStories": { + "type": "boolean", + "description": "Automatically generate *.stories.ts files for components declared in this library", + "x-prompt": "Automatically generate story files for components declared in this library?" + }, "js": { "type": "boolean", "description": "Generate JavaScript files rather than TypeScript files", diff --git a/packages/react/src/utils/ast-utils.spec.ts b/packages/react/src/utils/ast-utils.spec.ts index 700d48458a..cd6c9e1e8c 100644 --- a/packages/react/src/utils/ast-utils.spec.ts +++ b/packages/react/src/utils/ast-utils.spec.ts @@ -56,6 +56,41 @@ describe('findDefaultExport', () => { expect(result).toBeDefined(); expect(result.name.text).toEqual('main'); }); + + it('should find default class export', () => { + const sourceCode = ` + export default class Main {}; + `; + const source = ts.createSourceFile( + 'test.ts', + sourceCode, + ts.ScriptTarget.Latest, + true + ); + + const result = utils.findDefaultExport(source) as any; + + expect(result).toBeDefined(); + expect(result.name.text).toEqual('Main'); + }); + + it('should find exported class', () => { + const sourceCode = ` + class Main {}; + export default Main; + `; + const source = ts.createSourceFile( + 'test.ts', + sourceCode, + ts.ScriptTarget.Latest, + true + ); + + const result = utils.findDefaultExport(source) as any; + + expect(result).toBeDefined(); + expect(result.name.text).toEqual('Main'); + }); }); describe('addRoute', () => { @@ -361,3 +396,103 @@ const store = createStore(combineReducer({})); expect(result).toContain('[SLICE_KEY]: sliceReducer'); }); }); + +describe('getComponentName', () => { + [ + { + testName: 'exporting a function', + src: `export default function Test(props: TestProps) { + return ( +
+

Welcome to test component, {props.name}

+
+ );`, + expectedName: 'Test' + }, + { + testName: 'defining a function and then default exporting it', + src: ` + function Test(props: TestProps) { + return ( +
+

Welcome to test component, {props.name}

+
+ ); + }; + export default Test; + `, + expectedName: 'Test' + }, + { + testName: 'defining an arrow function and then exporting it', + src: ` + const Test = (props: TestProps) => { + return ( +
+

Welcome to test component, {props.name}

+
+ ); + }; + export default Test; + `, + expectedName: 'Test' + }, + { + testName: 'defining an arrow function that directly returns JSX', + src: ` + const Test = (props: TestProps) =>

Welcome to test component, {props.name}

; + export default Test + `, + expectedName: 'Test' + }, + { + testName: 'exporting a react class component', + src: ` + export default class Test extends React.Component { + render() { + return

Welcome to test component, {this.props.name}

; + } + } + `, + expectedName: 'Test' + }, + { + testName: 'defining a react class component & then default exporting it', + src: ` + export default class Test extends React.Component { + render() { + return

Welcome to test component, {this.props.name}

; + } + } + `, + expectedName: 'Test' + } + ].forEach(testConfig => { + it(`should find the component when ${testConfig.testName}`, () => { + const source = ts.createSourceFile( + 'some-component.tsx', + testConfig.src, + ts.ScriptTarget.Latest, + true + ); + + const result = utils.getComponentName(source) as any; + + expect(result).toBeDefined(); + expect((result as any).name.text).toEqual(testConfig.expectedName); + }); + }); + + it('should return null if there is no component', () => { + const source = ts.createSourceFile( + 'some-component.tsx', + `console.log('hi there');`, + ts.ScriptTarget.Latest, + true + ); + + const result = utils.getComponentName(source) as any; + + expect(result).toBeNull(); + }); +}); diff --git a/packages/react/src/utils/ast-utils.ts b/packages/react/src/utils/ast-utils.ts index a1b0685e1e..37fb373ba6 100644 --- a/packages/react/src/utils/ast-utils.ts +++ b/packages/react/src/utils/ast-utils.ts @@ -6,7 +6,10 @@ import { ReplaceChange } from '@nrwl/workspace/src/utils/ast-utils'; import * as ts from 'typescript'; -import { SchematicContext } from '@angular-devkit/schematics'; +import { + SchematicContext, + SchematicsException +} from '@angular-devkit/schematics'; export function findMainRenderStatement( source: ts.SourceFile @@ -67,8 +70,9 @@ export function findDefaultExportDeclaration( if (identifier) { const variables = findNodes(source, ts.SyntaxKind.VariableDeclaration); const fns = findNodes(source, ts.SyntaxKind.FunctionDeclaration); - const all = variables.concat(fns) as Array< - ts.VariableDeclaration | ts.FunctionDeclaration + const cls = findNodes(source, ts.SyntaxKind.ClassDeclaration); + const all = [...variables, ...fns, ...cls] as Array< + ts.VariableDeclaration | ts.FunctionDeclaration | ts.ClassDeclaration >; const exported = all @@ -119,6 +123,7 @@ function hasDefaultExportModifier( x: ts.ClassDeclaration | ts.FunctionDeclaration ) { return ( + x.modifiers && x.modifiers.some(m => m.kind === ts.SyntaxKind.ExportKeyword) && x.modifiers.some(m => m.kind === ts.SyntaxKind.DefaultKeyword) ); @@ -445,3 +450,85 @@ export function updateReduxStore( ) ]; } + +export function getComponentName(sourceFile: ts.SourceFile): ts.Node | null { + const defaultExport = findDefaultExport(sourceFile); + + if ( + !( + defaultExport && + findNodes(defaultExport, ts.SyntaxKind.JsxElement).length > 0 + ) + ) { + return null; + } + + return defaultExport; +} + +export function getComponentPropsInterface( + sourceFile: ts.SourceFile +): ts.InterfaceDeclaration | null { + const cmpDeclaration = getComponentName(sourceFile); + let propsTypeName: string = null; + + if (ts.isFunctionDeclaration(cmpDeclaration)) { + const propsParam: ts.ParameterDeclaration = cmpDeclaration.parameters.find( + x => ts.isParameter(x) && (x.name as ts.Identifier).text === 'props' + ); + + if (propsParam && propsParam.type) { + propsTypeName = ((propsParam.type as ts.TypeReferenceNode) + .typeName as ts.Identifier).text; + } + } else if ( + (cmpDeclaration as ts.VariableDeclaration).initializer && + ts.isArrowFunction((cmpDeclaration as ts.VariableDeclaration).initializer) + ) { + const arrowFn = (cmpDeclaration as ts.VariableDeclaration) + .initializer as ts.ArrowFunction; + + const propsParam: ts.ParameterDeclaration = arrowFn.parameters.find( + x => ts.isParameter(x) && (x.name as ts.Identifier).text === 'props' + ); + + if (propsParam && propsParam.type) { + propsTypeName = ((propsParam.type as ts.TypeReferenceNode) + .typeName as ts.Identifier).text; + } + } else if ( + // do we have a class component extending from React.Component + ts.isClassDeclaration(cmpDeclaration) && + cmpDeclaration.heritageClauses && + cmpDeclaration.heritageClauses.length > 0 + ) { + const heritageClause = cmpDeclaration.heritageClauses[0]; + + if (heritageClause) { + const propsTypeExpression = heritageClause.types.find( + x => + (x.expression as ts.PropertyAccessExpression).name.text === + 'Component' || + (x.expression as ts.PropertyAccessExpression).name.text === + 'PureComponent' + ); + + if (propsTypeExpression && propsTypeExpression.typeArguments) { + propsTypeName = (propsTypeExpression + .typeArguments[0] as ts.TypeReferenceNode).typeName.getText(); + } + } + } else { + return null; + } + + if (propsTypeName) { + return findNodes(sourceFile, ts.SyntaxKind.InterfaceDeclaration).find( + (x: ts.InterfaceDeclaration) => { + return (x.name as ts.Identifier).getText() === propsTypeName; + } + ) as ts.InterfaceDeclaration; + } else { + return null; + } +} diff --git a/packages/storybook/src/schematics/configuration/lib-files/.storybook/config.js__tmpl__ b/packages/storybook/src/schematics/configuration/lib-files/.storybook/config.js__tmpl__ index bd9a03d746..72c809f190 100644 --- a/packages/storybook/src/schematics/configuration/lib-files/.storybook/config.js__tmpl__ +++ b/packages/storybook/src/schematics/configuration/lib-files/.storybook/config.js__tmpl__ @@ -2,4 +2,4 @@ import { configure, addDecorator } from '<%= uiFramework %>'; import { withKnobs } from '@storybook/addon-knobs'; addDecorator(withKnobs); -configure(require.context('../src/lib', true, /\.stories\.tsx?$/), module); +configure(require.context('../src/lib', true, /\.stories\.(j|t)sx?$/), module); diff --git a/packages/storybook/src/schematics/init/init.ts b/packages/storybook/src/schematics/init/init.ts index b88ce73196..0785a7a099 100644 --- a/packages/storybook/src/schematics/init/init.ts +++ b/packages/storybook/src/schematics/init/init.ts @@ -13,7 +13,8 @@ import { babelLoaderVersion, babelCoreVersion, storybookVersion, - nxVersion + nxVersion, + babelPresetTypescriptVersion } from '../../utils/versions'; import { Schema } from './schema'; @@ -29,6 +30,9 @@ function checkDependenciesInstalled(): Rule { devDependencies['@storybook/addon-knobs'] = storybookVersion; devDependencies['babel-loader'] = babelLoaderVersion; devDependencies['@babel/core'] = babelCoreVersion; + devDependencies[ + '@babel/preset-typescript' + ] = babelPresetTypescriptVersion; } if ( !packageJson.dependencies['@angular/forms'] && diff --git a/packages/storybook/src/utils/versions.ts b/packages/storybook/src/utils/versions.ts index 19f8ea07d6..26b3e9b012 100644 --- a/packages/storybook/src/utils/versions.ts +++ b/packages/storybook/src/utils/versions.ts @@ -2,3 +2,4 @@ export const nxVersion = '*'; export const storybookVersion = '5.3.9'; export const babelCoreVersion = '7.8.3'; export const babelLoaderVersion = '8.0.6'; +export const babelPresetTypescriptVersion = '7.8.3'; diff --git a/packages/workspace/src/utils/ast-utils.ts b/packages/workspace/src/utils/ast-utils.ts index 5ae9dd86d2..b712a7aeb1 100644 --- a/packages/workspace/src/utils/ast-utils.ts +++ b/packages/workspace/src/utils/ast-utils.ts @@ -11,7 +11,11 @@ import { SchematicContext, DirEntry, noop, - chain + chain, + Source, + mergeWith, + apply, + forEach } from '@angular-devkit/schematics'; import * as ts from 'typescript'; import * as stripJsonComments from 'strip-json-comments'; @@ -795,3 +799,24 @@ function renameFile(tree: Tree, from: string, to: string) { tree.create(to, buffer.toString()); tree.delete(from); } + +/** + * Applies a template merge but skips for already existing entries + */ +export function applyWithSkipExisting(source: Source, rules: Rule[]): Rule { + return (tree: Tree, _context: SchematicContext) => { + const rule = mergeWith( + apply(source, [ + ...rules, + forEach(fileEntry => { + if (tree.exists(fileEntry.path)) { + return null; + } + return fileEntry; + }) + ]) + ); + + return rule(tree, _context); + }; +} diff --git a/packages/workspace/src/utils/lint.ts b/packages/workspace/src/utils/lint.ts index 8baf26ddd3..1c8c36a4a7 100644 --- a/packages/workspace/src/utils/lint.ts +++ b/packages/workspace/src/utils/lint.ts @@ -129,6 +129,7 @@ export function addLintFiles( : [options.localConfig.extends] : []; configJson = { + rules: {}, ...options.localConfig, extends: [...extendsOption, rootConfig] };