feat(react): improve storybook schematics (#2831)

This commit is contained in:
Juri Strumpflohner 2020-04-27 18:47:23 +02:00 committed by GitHub
parent b1b6b84af9
commit a47c24c05f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1873 additions and 41 deletions

View 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

View 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

View 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

View File

@ -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`

View 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

View 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

View 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

View File

@ -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`

View File

@ -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)]);

View File

@ -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;

View File

@ -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);
};
}

View File

@ -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)

View File

@ -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"]
}

View File

@ -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
}
}
}

View File

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

View File

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

View File

@ -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%>!');
});
});

View File

@ -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"]
}

View File

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

View 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()]);
}

View File

@ -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 %>} <% } %> />;
};

View 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"]
}

View 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"]
}

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

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

View File

@ -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',
<StorybookConfigureSchema>{
@ -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',
<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();
appTree = createEmptyWorkspace(appTree);
appTree = await callRule(
externalSchematic('@nrwl/react', 'library', {
name: libName
name: libName,
js: plainJS
}),
appTree
);

View File

@ -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<StorybookStoriesSchema>('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()
]);
}

View File

@ -1,5 +1,7 @@
export interface StorybookConfigureSchema {
name: string;
configureCypress: boolean;
generateStories?: boolean;
generateCypressSpecs?: boolean;
js?: boolean;
}

View File

@ -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",

View File

@ -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 (
<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();
});
});

View File

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

View File

@ -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);

View File

@ -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'] &&

View File

@ -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';

View File

@ -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);
};
}

View File

@ -129,6 +129,7 @@ export function addLintFiles(
: [options.localConfig.extends]
: [];
configJson = {
rules: {},
...options.localConfig,
extends: [...extendsOption, rootConfig]
};