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]
};