feat(react): improve storybook schematics (#2831)
This commit is contained in:
parent
b1b6b84af9
commit
a47c24c05f
45
docs/angular/api-react/schematics/component-cypress-spec.md
Normal file
45
docs/angular/api-react/schematics/component-cypress-spec.md
Normal file
@ -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
|
||||||
37
docs/angular/api-react/schematics/component-story.md
Normal file
37
docs/angular/api-react/schematics/component-story.md
Normal file
@ -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
|
||||||
45
docs/angular/api-react/schematics/stories.md
Normal file
45
docs/angular/api-react/schematics/stories.md
Normal file
@ -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
|
||||||
@ -30,6 +30,12 @@ Type: `boolean`
|
|||||||
|
|
||||||
Run the cypress-configure schematic
|
Run the cypress-configure schematic
|
||||||
|
|
||||||
|
### generateStories
|
||||||
|
|
||||||
|
Type: `boolean`
|
||||||
|
|
||||||
|
Automatically generate \*.stories.ts files for components declared in this library
|
||||||
|
|
||||||
### js
|
### js
|
||||||
|
|
||||||
Default: `false`
|
Default: `false`
|
||||||
|
|||||||
45
docs/react/api-react/schematics/component-cypress-spec.md
Normal file
45
docs/react/api-react/schematics/component-cypress-spec.md
Normal file
@ -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
|
||||||
37
docs/react/api-react/schematics/component-story.md
Normal file
37
docs/react/api-react/schematics/component-story.md
Normal file
@ -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
|
||||||
45
docs/react/api-react/schematics/stories.md
Normal file
45
docs/react/api-react/schematics/stories.md
Normal file
@ -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
|
||||||
@ -30,6 +30,12 @@ Type: `boolean`
|
|||||||
|
|
||||||
Run the cypress-configure schematic
|
Run the cypress-configure schematic
|
||||||
|
|
||||||
|
### generateStories
|
||||||
|
|
||||||
|
Type: `boolean`
|
||||||
|
|
||||||
|
Automatically generate \*.stories.ts files for components declared in this library
|
||||||
|
|
||||||
### js
|
### js
|
||||||
|
|
||||||
Default: `false`
|
Default: `false`
|
||||||
|
|||||||
@ -16,15 +16,12 @@ import {
|
|||||||
PropertyDeclaration,
|
PropertyDeclaration,
|
||||||
SyntaxKind
|
SyntaxKind
|
||||||
} from 'typescript';
|
} from 'typescript';
|
||||||
import {
|
import { getTsSourceFile, getDecoratorMetadata } from '../../utils/ast-utils';
|
||||||
getTsSourceFile,
|
|
||||||
getDecoratorMetadata,
|
|
||||||
applyWithSkipExisting
|
|
||||||
} from '../../utils/ast-utils';
|
|
||||||
import {
|
import {
|
||||||
getInputPropertyDeclarations,
|
getInputPropertyDeclarations,
|
||||||
getKnobType
|
getKnobType
|
||||||
} from '../component-story/component-story';
|
} from '../component-story/component-story';
|
||||||
|
import { applyWithSkipExisting } from '@nrwl/workspace/src/utils/ast-utils';
|
||||||
|
|
||||||
export default function(schema: CreateComponentSpecFileSchema): Rule {
|
export default function(schema: CreateComponentSpecFileSchema): Rule {
|
||||||
return chain([createComponentSpecFile(schema)]);
|
return chain([createComponentSpecFile(schema)]);
|
||||||
|
|||||||
@ -9,8 +9,11 @@ import {
|
|||||||
} from '@angular-devkit/schematics';
|
} from '@angular-devkit/schematics';
|
||||||
import { findNodes } from '@nrwl/workspace';
|
import { findNodes } from '@nrwl/workspace';
|
||||||
import { PropertyDeclaration, SyntaxKind } from 'typescript';
|
import { PropertyDeclaration, SyntaxKind } from 'typescript';
|
||||||
import { getTsSourceFile, applyWithSkipExisting } from '../../utils/ast-utils';
|
import { getTsSourceFile } from '../../utils/ast-utils';
|
||||||
import { getSourceNodes } from '@nrwl/workspace/src/utils/ast-utils';
|
import {
|
||||||
|
getSourceNodes,
|
||||||
|
applyWithSkipExisting
|
||||||
|
} from '@nrwl/workspace/src/utils/ast-utils';
|
||||||
|
|
||||||
export interface CreateComponentStoriesFileSchema {
|
export interface CreateComponentStoriesFileSchema {
|
||||||
libPath: string;
|
libPath: string;
|
||||||
|
|||||||
@ -641,21 +641,3 @@ export function getTsSourceFile(host: Tree, path: string): ts.SourceFile {
|
|||||||
|
|
||||||
return source;
|
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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@ -92,7 +92,23 @@ function updateWorkspaceJson(options: CypressProjectSchema): Rule {
|
|||||||
export default function(options: CypressProjectSchema): Rule {
|
export default function(options: CypressProjectSchema): Rule {
|
||||||
options = normalizeOptions(options);
|
options = normalizeOptions(options);
|
||||||
return chain([
|
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),
|
generateFiles(options),
|
||||||
updateWorkspaceJson(options),
|
updateWorkspaceJson(options),
|
||||||
updateNxJson(options)
|
updateNxJson(options)
|
||||||
|
|||||||
@ -2,7 +2,8 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
"outDir": "<%= offsetFromRoot %>dist/out-tsc"
|
"outDir": "<%= offsetFromRoot %>dist/out-tsc",
|
||||||
|
"allowJs": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.js"]
|
"include": ["src/**/*.ts", "src/**/*.js"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,6 +44,27 @@
|
|||||||
"schema": "./src/schematics/storybook-configuration/schema.json",
|
"schema": "./src/schematics/storybook-configuration/schema.json",
|
||||||
"description": "Set up storybook for a react library",
|
"description": "Set up storybook for a react library",
|
||||||
"hidden": false
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to test component, {props.name}</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Test;
|
||||||
|
`,
|
||||||
|
testCmpSrcWithoutProps: `import React from 'react';
|
||||||
|
|
||||||
|
import './test.scss';
|
||||||
|
|
||||||
|
export const Test = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to test component</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Test;
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
plainJS: true,
|
||||||
|
testCmpSrcWithProps: `import React from 'react';
|
||||||
|
|
||||||
|
import './test.scss';
|
||||||
|
export const Test = (props: TestProps) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to test component, {props.name}</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Test;
|
||||||
|
`,
|
||||||
|
testCmpSrcWithoutProps: `import React from 'react';
|
||||||
|
import './test.scss';
|
||||||
|
export const Test = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to test component</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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',
|
||||||
|
<CreateComponentSpecFileSchema>{
|
||||||
|
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',
|
||||||
|
<CreateComponentSpecFileSchema>{
|
||||||
|
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<Tree> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -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<number, any> = {
|
||||||
|
[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)
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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%>!');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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"]
|
||||||
|
}
|
||||||
@ -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',
|
||||||
|
<CreateComponentStoriesFileSchema>{
|
||||||
|
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',
|
||||||
|
<CreateComponentStoriesFileSchema>{
|
||||||
|
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 <TestUiLib />;
|
||||||
|
};`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to test component</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Test;
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
tree = await runSchematic(
|
||||||
|
'component-story',
|
||||||
|
<CreateComponentStoriesFileSchema>{
|
||||||
|
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 <Test />;
|
||||||
|
};`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('component without any props defined', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
appTree.overwrite(
|
||||||
|
cmpPath,
|
||||||
|
`import React from 'react';
|
||||||
|
|
||||||
|
import './test.scss';
|
||||||
|
|
||||||
|
export const Test = () => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to test component</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Test;
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
tree = await runSchematic(
|
||||||
|
'component-story',
|
||||||
|
<CreateComponentStoriesFileSchema>{
|
||||||
|
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 <Test />;
|
||||||
|
};
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to test component, {props.name}</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Test;
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
tree = await runSchematic(
|
||||||
|
'component-story',
|
||||||
|
<CreateComponentStoriesFileSchema>{
|
||||||
|
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 <Test name={props.name} displayAge={props.displayAge} />;
|
||||||
|
};
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'default export function',
|
||||||
|
src: `export default function Test(props: TestProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to test component, {props.name}</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'function and then export',
|
||||||
|
src: `
|
||||||
|
function Test(props: TestProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to test component, {props.name}</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Test;
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'arrow function',
|
||||||
|
src: `
|
||||||
|
const Test = (props: TestProps) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to test component, {props.name}</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Test;
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'arrow function without {..}',
|
||||||
|
src: `
|
||||||
|
const Test = (props: TestProps) => <div><h1>Welcome to test component, {props.name}</h1></div>;
|
||||||
|
export default Test
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'direct export of component class',
|
||||||
|
src: `
|
||||||
|
export default class Test extends React.Component<TestProps> {
|
||||||
|
render() {
|
||||||
|
return <div><h1>Welcome to test component, {this.props.name}</h1></div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'component class & then default export',
|
||||||
|
src: `
|
||||||
|
class Test extends React.Component<TestProps> {
|
||||||
|
render() {
|
||||||
|
return <div><h1>Welcome to test component, {this.props.name}</h1></div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default Test
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'PureComponent class & then default export',
|
||||||
|
src: `
|
||||||
|
class Test extends React.PureComponent<TestProps> {
|
||||||
|
render() {
|
||||||
|
return <div><h1>Welcome to test component, {this.props.name}</h1></div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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',
|
||||||
|
<CreateComponentStoriesFileSchema>{
|
||||||
|
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 <Test name={props.name} displayAge={props.displayAge} />;
|
||||||
|
};`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('using tslint', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
appTree = await createTestUILib('test-ui-lib', false);
|
||||||
|
tree = await runSchematic(
|
||||||
|
'component-story',
|
||||||
|
<CreateComponentStoriesFileSchema>{
|
||||||
|
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 <TestUiLib />;
|
||||||
|
};`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createTestUILib(
|
||||||
|
libName: string,
|
||||||
|
useEsLint = false
|
||||||
|
): Promise<Tree> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
148
packages/react/src/schematics/component-story/component-story.ts
Normal file
148
packages/react/src/schematics/component-story/component-story.ts
Normal file
@ -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<number, any> = {
|
||||||
|
[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<number, KnobType> = {
|
||||||
|
[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()]);
|
||||||
|
}
|
||||||
@ -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 %>} <% } %> />;
|
||||||
|
};
|
||||||
24
packages/react/src/schematics/component-story/schema.json
Normal file
24
packages/react/src/schematics/component-story/schema.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
27
packages/react/src/schematics/stories/schema.json
Normal file
27
packages/react/src/schematics/stories/schema.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
127
packages/react/src/schematics/stories/stories.spec.ts
Normal file
127
packages/react/src/schematics/stories/stories.spec.ts
Normal file
@ -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 (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to test component, {props.name}</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Test;
|
||||||
|
`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the stories', async () => {
|
||||||
|
tree = await runSchematic(
|
||||||
|
'stories',
|
||||||
|
<StorybookStoriesSchema>{
|
||||||
|
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',
|
||||||
|
<StorybookStoriesSchema>{
|
||||||
|
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',
|
||||||
|
<StorybookStoriesSchema>{
|
||||||
|
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<Tree> {
|
||||||
|
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<Schema>),
|
||||||
|
appTree
|
||||||
|
);
|
||||||
|
return appTree;
|
||||||
|
}
|
||||||
98
packages/react/src/schematics/stories/stories.ts
Normal file
98
packages/react/src/schematics/stories/stories.ts
Normal file
@ -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<CreateComponentStoriesFileSchema>('component-story', {
|
||||||
|
componentPath: relativeCmpDir,
|
||||||
|
project: projectName
|
||||||
|
}),
|
||||||
|
generateCypressSpecs
|
||||||
|
? schematic<CreateComponentSpecFileSchema>(
|
||||||
|
'component-cypress-spec',
|
||||||
|
{
|
||||||
|
project: projectName,
|
||||||
|
componentPath: relativeCmpDir,
|
||||||
|
js
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: () => {}
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function(schema: StorybookStoriesSchema): Rule {
|
||||||
|
return chain([
|
||||||
|
createAllStories(schema.project, schema.generateCypressSpecs, schema.js)
|
||||||
|
]);
|
||||||
|
}
|
||||||
@ -1,11 +1,18 @@
|
|||||||
import { Tree, schematic, externalSchematic } from '@angular-devkit/schematics';
|
import { externalSchematic, Tree } from '@angular-devkit/schematics';
|
||||||
import { runSchematic, callRule } from '../../utils/testing';
|
|
||||||
import { StorybookConfigureSchema } from './schema';
|
|
||||||
import { createEmptyWorkspace } from '@nrwl/workspace/testing';
|
import { createEmptyWorkspace } from '@nrwl/workspace/testing';
|
||||||
|
import { callRule, runSchematic } from '../../utils/testing';
|
||||||
|
import { StorybookConfigureSchema } from './schema';
|
||||||
|
|
||||||
describe('react:storybook-configuration', () => {
|
describe('react:storybook-configuration', () => {
|
||||||
|
let appTree;
|
||||||
|
|
||||||
|
// beforeEach(async () => {
|
||||||
|
// appTree = await createTestUILib('test-ui-lib');
|
||||||
|
// });
|
||||||
|
|
||||||
it('should configure everything at once', async () => {
|
it('should configure everything at once', async () => {
|
||||||
const appTree = await createTestUILib('test-ui-lib');
|
appTree = await createTestUILib('test-ui-lib');
|
||||||
|
|
||||||
const tree = await runSchematic(
|
const tree = await runSchematic(
|
||||||
'storybook-configuration',
|
'storybook-configuration',
|
||||||
<StorybookConfigureSchema>{
|
<StorybookConfigureSchema>{
|
||||||
@ -21,14 +28,72 @@ describe('react:storybook-configuration', () => {
|
|||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(tree.exists('apps/test-ui-lib-e2e/cypress.json')).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',
|
||||||
|
<StorybookConfigureSchema>{
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to test component</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Test;
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
const tree = await runSchematic(
|
||||||
|
'storybook-configuration',
|
||||||
|
<StorybookConfigureSchema>{
|
||||||
|
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<Tree> {
|
export async function createTestUILib(
|
||||||
|
libName: string,
|
||||||
|
plainJS = false
|
||||||
|
): Promise<Tree> {
|
||||||
let appTree = Tree.empty();
|
let appTree = Tree.empty();
|
||||||
appTree = createEmptyWorkspace(appTree);
|
appTree = createEmptyWorkspace(appTree);
|
||||||
appTree = await callRule(
|
appTree = await callRule(
|
||||||
externalSchematic('@nrwl/react', 'library', {
|
externalSchematic('@nrwl/react', 'library', {
|
||||||
name: libName
|
name: libName,
|
||||||
|
js: plainJS
|
||||||
}),
|
}),
|
||||||
appTree
|
appTree
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 { StorybookConfigureSchema } from './schema';
|
||||||
|
import { StorybookStoriesSchema } from '../stories/stories';
|
||||||
|
|
||||||
|
function generateStories(schema: StorybookConfigureSchema): Rule {
|
||||||
|
return (tree, context) => {
|
||||||
|
return schematic<StorybookStoriesSchema>('stories', {
|
||||||
|
project: schema.name,
|
||||||
|
generateCypressSpecs:
|
||||||
|
schema.configureCypress && schema.generateCypressSpecs,
|
||||||
|
js: schema.js
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function(schema: StorybookConfigureSchema): Rule {
|
export default function(schema: StorybookConfigureSchema): Rule {
|
||||||
return chain([
|
return chain([
|
||||||
@ -8,6 +26,7 @@ export default function(schema: StorybookConfigureSchema): Rule {
|
|||||||
uiFramework: '@storybook/react',
|
uiFramework: '@storybook/react',
|
||||||
configureCypress: schema.configureCypress,
|
configureCypress: schema.configureCypress,
|
||||||
js: schema.js
|
js: schema.js
|
||||||
})
|
}),
|
||||||
|
schema.generateStories ? generateStories(schema) : noop()
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
export interface StorybookConfigureSchema {
|
export interface StorybookConfigureSchema {
|
||||||
name: string;
|
name: string;
|
||||||
configureCypress: boolean;
|
configureCypress: boolean;
|
||||||
|
generateStories?: boolean;
|
||||||
|
generateCypressSpecs?: boolean;
|
||||||
js?: boolean;
|
js?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,11 @@
|
|||||||
"description": "Run the cypress-configure schematic",
|
"description": "Run the cypress-configure schematic",
|
||||||
"x-prompt": "Configure a cypress e2e app to run against the storybook instance?"
|
"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": {
|
"js": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Generate JavaScript files rather than TypeScript files",
|
"description": "Generate JavaScript files rather than TypeScript files",
|
||||||
|
|||||||
@ -56,6 +56,41 @@ describe('findDefaultExport', () => {
|
|||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result.name.text).toEqual('main');
|
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', () => {
|
describe('addRoute', () => {
|
||||||
@ -361,3 +396,103 @@ const store = createStore(combineReducer({}));
|
|||||||
expect(result).toContain('[SLICE_KEY]: sliceReducer');
|
expect(result).toContain('[SLICE_KEY]: sliceReducer');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getComponentName', () => {
|
||||||
|
[
|
||||||
|
{
|
||||||
|
testName: 'exporting a function',
|
||||||
|
src: `export default function Test(props: TestProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to test component, {props.name}</h1>
|
||||||
|
</div>
|
||||||
|
);`,
|
||||||
|
expectedName: 'Test'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: 'defining a function and then default exporting it',
|
||||||
|
src: `
|
||||||
|
function Test(props: TestProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to test component, {props.name}</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Test;
|
||||||
|
`,
|
||||||
|
expectedName: 'Test'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: 'defining an arrow function and then exporting it',
|
||||||
|
src: `
|
||||||
|
const Test = (props: TestProps) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Welcome to test component, {props.name}</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Test;
|
||||||
|
`,
|
||||||
|
expectedName: 'Test'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: 'defining an arrow function that directly returns JSX',
|
||||||
|
src: `
|
||||||
|
const Test = (props: TestProps) => <div><h1>Welcome to test component, {props.name}</h1></div>;
|
||||||
|
export default Test
|
||||||
|
`,
|
||||||
|
expectedName: 'Test'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: 'exporting a react class component',
|
||||||
|
src: `
|
||||||
|
export default class Test extends React.Component<TestProps> {
|
||||||
|
render() {
|
||||||
|
return <div><h1>Welcome to test component, {this.props.name}</h1></div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
expectedName: 'Test'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
testName: 'defining a react class component & then default exporting it',
|
||||||
|
src: `
|
||||||
|
export default class Test extends React.Component<TestProps> {
|
||||||
|
render() {
|
||||||
|
return <div><h1>Welcome to test component, {this.props.name}</h1></div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -6,7 +6,10 @@ import {
|
|||||||
ReplaceChange
|
ReplaceChange
|
||||||
} from '@nrwl/workspace/src/utils/ast-utils';
|
} from '@nrwl/workspace/src/utils/ast-utils';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
import { SchematicContext } from '@angular-devkit/schematics';
|
import {
|
||||||
|
SchematicContext,
|
||||||
|
SchematicsException
|
||||||
|
} from '@angular-devkit/schematics';
|
||||||
|
|
||||||
export function findMainRenderStatement(
|
export function findMainRenderStatement(
|
||||||
source: ts.SourceFile
|
source: ts.SourceFile
|
||||||
@ -67,8 +70,9 @@ export function findDefaultExportDeclaration(
|
|||||||
if (identifier) {
|
if (identifier) {
|
||||||
const variables = findNodes(source, ts.SyntaxKind.VariableDeclaration);
|
const variables = findNodes(source, ts.SyntaxKind.VariableDeclaration);
|
||||||
const fns = findNodes(source, ts.SyntaxKind.FunctionDeclaration);
|
const fns = findNodes(source, ts.SyntaxKind.FunctionDeclaration);
|
||||||
const all = variables.concat(fns) as Array<
|
const cls = findNodes(source, ts.SyntaxKind.ClassDeclaration);
|
||||||
ts.VariableDeclaration | ts.FunctionDeclaration
|
const all = [...variables, ...fns, ...cls] as Array<
|
||||||
|
ts.VariableDeclaration | ts.FunctionDeclaration | ts.ClassDeclaration
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const exported = all
|
const exported = all
|
||||||
@ -119,6 +123,7 @@ function hasDefaultExportModifier(
|
|||||||
x: ts.ClassDeclaration | ts.FunctionDeclaration
|
x: ts.ClassDeclaration | ts.FunctionDeclaration
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
|
x.modifiers &&
|
||||||
x.modifiers.some(m => m.kind === ts.SyntaxKind.ExportKeyword) &&
|
x.modifiers.some(m => m.kind === ts.SyntaxKind.ExportKeyword) &&
|
||||||
x.modifiers.some(m => m.kind === ts.SyntaxKind.DefaultKeyword)
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -2,4 +2,4 @@ import { configure, addDecorator } from '<%= uiFramework %>';
|
|||||||
import { withKnobs } from '@storybook/addon-knobs';
|
import { withKnobs } from '@storybook/addon-knobs';
|
||||||
|
|
||||||
addDecorator(withKnobs);
|
addDecorator(withKnobs);
|
||||||
configure(require.context('../src/lib', true, /\.stories\.tsx?$/), module);
|
configure(require.context('../src/lib', true, /\.stories\.(j|t)sx?$/), module);
|
||||||
|
|||||||
@ -13,7 +13,8 @@ import {
|
|||||||
babelLoaderVersion,
|
babelLoaderVersion,
|
||||||
babelCoreVersion,
|
babelCoreVersion,
|
||||||
storybookVersion,
|
storybookVersion,
|
||||||
nxVersion
|
nxVersion,
|
||||||
|
babelPresetTypescriptVersion
|
||||||
} from '../../utils/versions';
|
} from '../../utils/versions';
|
||||||
import { Schema } from './schema';
|
import { Schema } from './schema';
|
||||||
|
|
||||||
@ -29,6 +30,9 @@ function checkDependenciesInstalled(): Rule {
|
|||||||
devDependencies['@storybook/addon-knobs'] = storybookVersion;
|
devDependencies['@storybook/addon-knobs'] = storybookVersion;
|
||||||
devDependencies['babel-loader'] = babelLoaderVersion;
|
devDependencies['babel-loader'] = babelLoaderVersion;
|
||||||
devDependencies['@babel/core'] = babelCoreVersion;
|
devDependencies['@babel/core'] = babelCoreVersion;
|
||||||
|
devDependencies[
|
||||||
|
'@babel/preset-typescript'
|
||||||
|
] = babelPresetTypescriptVersion;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!packageJson.dependencies['@angular/forms'] &&
|
!packageJson.dependencies['@angular/forms'] &&
|
||||||
|
|||||||
@ -2,3 +2,4 @@ export const nxVersion = '*';
|
|||||||
export const storybookVersion = '5.3.9';
|
export const storybookVersion = '5.3.9';
|
||||||
export const babelCoreVersion = '7.8.3';
|
export const babelCoreVersion = '7.8.3';
|
||||||
export const babelLoaderVersion = '8.0.6';
|
export const babelLoaderVersion = '8.0.6';
|
||||||
|
export const babelPresetTypescriptVersion = '7.8.3';
|
||||||
|
|||||||
@ -11,7 +11,11 @@ import {
|
|||||||
SchematicContext,
|
SchematicContext,
|
||||||
DirEntry,
|
DirEntry,
|
||||||
noop,
|
noop,
|
||||||
chain
|
chain,
|
||||||
|
Source,
|
||||||
|
mergeWith,
|
||||||
|
apply,
|
||||||
|
forEach
|
||||||
} from '@angular-devkit/schematics';
|
} from '@angular-devkit/schematics';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
import * as stripJsonComments from 'strip-json-comments';
|
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.create(to, buffer.toString());
|
||||||
tree.delete(from);
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -129,6 +129,7 @@ export function addLintFiles(
|
|||||||
: [options.localConfig.extends]
|
: [options.localConfig.extends]
|
||||||
: [];
|
: [];
|
||||||
configJson = {
|
configJson = {
|
||||||
|
rules: {},
|
||||||
...options.localConfig,
|
...options.localConfig,
|
||||||
extends: [...extendsOption, rootConfig]
|
extends: [...extendsOption, rootConfig]
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user