feat(react-native): add storybook to react native (#8886)

This commit is contained in:
Emily Xiong 2022-02-14 10:13:23 -05:00 committed by GitHub
parent b1e52b67f2
commit 19efdfc938
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 2050 additions and 67 deletions

View File

@ -0,0 +1,42 @@
---
title: '@nrwl/react-native:storybook executor'
description: 'Serve React Native Storybook'
---
# @nrwl/react-native:storybook
Serve React Native Storybook
Options can be configured in `workspace.json` when defining the executor, or when invoking it. Read more about how to configure targets and executors here: https://nx.dev/configuration/projectjson#targets.
## Options
### outputFile (_**required**_)
Default: `./.storybook/story-loader.js`
Type: `string`
The output file that will be written. It is relative to the project directory.
### pattern (_**required**_)
Default: `**/*.stories.@(js|jsx|ts|tsx|md)`
Type: `string`
The pattern of files to look at. It can be a specific file, or any valid glob. Note: if using the CLI, globs with \*_/_... must be escaped with quotes
### searchDir (_**required**_)
Type: `string`
The directory or directories, relative to the project root, to search for files in.
### silent
Default: `false`
Type: `boolean`
Silences output.

View File

@ -0,0 +1,42 @@
---
title: '@nrwl/react-native:component-story generator'
description: 'Generate storybook story for a react-native component'
---
# @nrwl/react-native:component-story
Generate storybook story for a react-native 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-native:component-story ...
```
Show what will be generated without writing to disk:
```bash
nx g component-story ... --dry-run
```
## Options
### componentPath (_**required**_)
Type: `string`
Relative path to the component file from the library root
### project (_**required**_)
Type: `string`
The project name where to add the components.

View File

@ -0,0 +1,36 @@
---
title: '@nrwl/react-native:stories generator'
description: 'Create stories for all components declared in an app or library'
---
# @nrwl/react-native:stories
Create stories for all components declared in an app or 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-native:stories ...
```
Show what will be generated without writing to disk:
```bash
nx g stories ... --dry-run
```
## Options
### project (_**required**_)
Type: `string`
Library or application name

View File

@ -0,0 +1,68 @@
---
title: '@nrwl/react-native:storybook-configuration generator'
description: 'Set up storybook for a react-native app or library'
---
# @nrwl/react-native:storybook-configuration
Set up storybook for a react-native app or library
## Usage
```bash
nx generate storybook-configuration ...
```
By default, Nx will search for `storybook-configuration` in the default collection provisioned in `workspace.json`.
You can specify the collection explicitly as follows:
```bash
nx g @nrwl/react-native:storybook-configuration ...
```
Show what will be generated without writing to disk:
```bash
nx g storybook-configuration ... --dry-run
```
## Options
### name (_**required**_)
Type: `string`
Project name
### generateStories
Default: `true`
Type: `boolean`
Automatically generate \*.stories.ts files for components declared in this project?
### js
Default: `false`
Type: `boolean`
Generate JavaScript files rather than TypeScript files.
### linter
Default: `eslint`
Type: `string`
Possible values: `eslint`, `tslint`
The tool to use for running lint checks.
### standaloneConfig
Type: `boolean`
Split the project configuration into <projectRoot>/project.json rather than including it inside workspace.json

View File

@ -1,11 +1,11 @@
---
title: '@nrwl/react:stories generator'
description: 'Create stories/specs for all components declared in a library'
description: 'Create stories/specs for all components declared in an app or library'
---
# @nrwl/react:stories
Create stories/specs for all components declared in a library
Create stories/specs for all components declared in an app or library
## Usage

View File

@ -1,11 +1,11 @@
---
title: '@nrwl/react:storybook-configuration generator'
description: 'Set up storybook for a react library'
description: 'Set up storybook for a react app or library'
---
# @nrwl/react:storybook-configuration
Set up storybook for a react library
Set up storybook for a react app or library
## Usage

View File

@ -1198,6 +1198,21 @@
"id": "library",
"file": "generated/api-react-native/generators/library"
},
{
"name": "component-story generator",
"id": "component-story",
"file": "generated/api-react-native/generators/component-story"
},
{
"name": "stories generator",
"id": "stories",
"file": "generated/api-react-native/generators/stories"
},
{
"name": "storybook-configuration generator",
"id": "storybook-configuration",
"file": "generated/api-react-native/generators/storybook-configuration"
},
{
"name": "build android executor",
"id": "build-android",
@ -1228,6 +1243,11 @@
"id": "start",
"file": "generated/api-react-native/executors/start"
},
{
"name": "storybook executor",
"id": "storybook",
"file": "generated/api-react-native/executors/storybook"
},
{
"name": "sync deps executor",
"id": "sync-deps",

View File

@ -57,37 +57,31 @@ describe('react native', () => {
).not.toThrow();
}, 1000000);
xit('should support create application with js', async () => {
it('should create storybook with application', async () => {
const appName = uniq('my-app');
runCLI(`generate @nrwl/react-native:application ${appName} --js`);
runCLI(`generate @nrwl/react-native:application ${appName}`);
runCLI(
`generate @nrwl/react-native:storybook-configuration ${appName} --generateStories --no-interactive`
);
expect(() =>
checkFilesExist(
`apps/${appName}/src/main.js`,
`apps/${appName}/src/app/App.js`,
`apps/${appName}/src/app/App.spec.js`
`.storybook/story-loader.js`,
`apps/${appName}/.storybook/storybook.ts`,
`apps/${appName}/.storybook/toggle-storybook.tsx`,
`apps/${appName}/src/app/App.stories.tsx`
)
).not.toThrow();
expectTestsPass(await runCLIAsync(`test ${appName}`));
const appLintResults = await runCLIAsync(`lint ${appName}`);
expect(appLintResults.combinedOutput).toContain('All files pass linting.');
const iosBundleResult = await runCLIAsync(`bundle-ios ${appName}`);
expect(iosBundleResult.combinedOutput).toContain(
'Done writing bundle output'
);
expect(() =>
checkFilesExist(`dist/apps/${appName}/ios/main.jsbundle`)
).not.toThrow();
const androidBundleResult = await runCLIAsync(`bundle-android ${appName}`);
expect(androidBundleResult.combinedOutput).toContain(
'Done writing bundle output'
);
expect(() =>
checkFilesExist(`dist/apps/${appName}/android/main.jsbundle`)
).not.toThrow();
await runCLIAsync(`storybook ${appName}`);
const result = readJson(join('apps', appName, 'package.json'));
expect(result).toMatchObject({
dependencies: {
'@storybook/addon-ondevice-actions': '*',
'@storybook/addon-ondevice-backgrounds': '*',
'@storybook/addon-ondevice-controls': '*',
'@storybook/addon-ondevice-notes': '*',
},
});
});
it('sync npm dependencies for autolink', async () => {

View File

@ -259,10 +259,10 @@
"zone.js": "~0.11.4"
},
"optionalDependencies": {
"@swc/core-linux-x64-musl": "^1.2.136",
"@swc/core-linux-x64-gnu": "^1.2.136",
"@swc/core-linux-arm64-gnu": "^1.2.136",
"@swc/core-linux-arm64-musl": "^1.2.136"
"@swc/core-linux-arm64-musl": "^1.2.136",
"@swc/core-linux-x64-gnu": "^1.2.136",
"@swc/core-linux-x64-musl": "^1.2.136"
},
"author": "Victor Savkin",
"license": "MIT",

View File

@ -34,6 +34,11 @@
"implementation": "./src/executors/ensure-symlink/ensure-symlink.impl",
"schema": "./src/executors/ensure-symlink//schema.json",
"description": "Ensure workspace node_modules is symlink under app's node_modules folder."
},
"storybook": {
"implementation": "./src/executors/storybook/storybook.impl",
"schema": "./src/executors/storybook/schema.json",
"description": "Serve React Native Storybook"
}
},
"builders": {
@ -71,6 +76,11 @@
"implementation": "./src/executors/ensure-symlink/compat",
"schema": "./src/executors/ensure-symlink//schema.json",
"description": "Ensure workspace node_modules is symlink under app's node_modules folder."
},
"storybook": {
"implementation": "./src/executors/storybook/compat",
"schema": "./src/executors/storybook/schema.json",
"description": "Serve React Native Storybook"
}
}
}

View File

@ -28,6 +28,24 @@
"schema": "./src/generators/component/schema.json",
"description": "Create a React Native component",
"aliases": ["c"]
},
"storybook-configuration": {
"factory": "./src/generators/storybook-configuration/configuration#storybookConfigurationSchematic",
"schema": "./src/generators/storybook-configuration/schema.json",
"description": "Set up storybook for a react-native app or library",
"hidden": false
},
"component-story": {
"factory": "./src/generators/component-story/component-story#componentStorySchematic",
"schema": "./src/generators/component-story/schema.json",
"description": "Generate storybook story for a react-native component",
"hidden": false
},
"stories": {
"factory": "./src/generators/stories/stories#storiesSchematic",
"schema": "./src/generators/stories/schema.json",
"description": "Create stories for all components declared in an app or library",
"hidden": false
}
},
"generators": {
@ -56,6 +74,24 @@
"schema": "./src/generators/component/schema.json",
"description": "Create a React Native component",
"aliases": ["c"]
},
"storybook-configuration": {
"factory": "./src/generators/storybook-configuration/configuration#storybookConfigurationGenerator",
"schema": "./src/generators/storybook-configuration/schema.json",
"description": "Set up storybook for a react-native app or library",
"hidden": false
},
"component-story": {
"factory": "./src/generators/component-story/component-story#componentStoryGenerator",
"schema": "./src/generators/component-story/schema.json",
"description": "Generate storybook story for a react-native component",
"hidden": false
},
"stories": {
"factory": "./src/generators/stories/stories#storiesGenerator",
"schema": "./src/generators/stories/schema.json",
"description": "Create stories/specs for all components declared in an app or library",
"hidden": false
}
}
}

View File

@ -28,6 +28,7 @@
"@nrwl/devkit": "*",
"@nrwl/jest": "*",
"@nrwl/linter": "*",
"@nrwl/storybook": "*",
"@nrwl/react": "*",
"@nrwl/workspace": "*",
"chalk": "^4.1.0",

View File

@ -0,0 +1,5 @@
import { convertNxExecutor } from '@nrwl/devkit';
import storybookExecutor from './storybook.impl';
export default convertNxExecutor(storybookExecutor);

View File

@ -0,0 +1,7 @@
// options from https://github.com/elderfo/react-native-storybook-loader#options
export interface ReactNativeStorybookOptions {
searchDir: string;
outputFile: string;
pattern: string;
silent: boolean;
}

View File

@ -0,0 +1,28 @@
{
"title": "React Native Storybook Load Stories",
"cli": "nx",
"description": "Load stories for react native",
"type": "object",
"properties": {
"searchDir": {
"type": "string",
"description": "The directory or directories, relative to the project root, to search for files in."
},
"outputFile": {
"type": "string",
"description": "The output file that will be written. It is relative to the project directory.",
"default": "./.storybook/story-loader.js"
},
"pattern": {
"type": "string",
"description": "The pattern of files to look at. It can be a specific file, or any valid glob. Note: if using the CLI, globs with **/*... must be escaped with quotes",
"default": "**/*.stories.@(js|jsx|ts|tsx|md)"
},
"silent": {
"type": "boolean",
"description": "Silences output.",
"default": false
}
},
"required": ["searchDir", "outputFile", "pattern"]
}

View File

@ -0,0 +1,92 @@
import { join } from 'path';
import { ExecutorContext, logger } from '@nrwl/devkit';
import * as chalk from 'chalk';
import { ReactNativeStorybookOptions } from './schema';
import { ChildProcess, fork } from 'child_process';
import {
displayNewlyAddedDepsMessage,
syncDeps,
} from '../sync-deps/sync-deps.impl';
let childProcess: ChildProcess;
export default async function* reactNatievStorybookExecutor(
options: ReactNativeStorybookOptions,
context: ExecutorContext
): AsyncGenerator<{ success: boolean }> {
const projectRoot = context.workspace.projects[context.projectName].root;
logger.info(
`${chalk.bold.cyan(
'info'
)} To see your Storybook stories on the device, you should start your mobile app for the <platform> of your choice (typically ios or android).`
);
// add storybook addons to app's package.json
displayNewlyAddedDepsMessage(
context.projectName,
await syncDeps(
context.projectName,
projectRoot,
context.root,
'@storybook/addon-ondevice-actions,@storybook/addon-ondevice-backgrounds,@storybook/addon-ondevice-controls,@storybook/addon-ondevice-notes'
)
);
try {
await runCliStorybook(context.root, projectRoot, options);
yield { success: true };
} finally {
if (childProcess) {
childProcess.kill();
}
}
}
function runCliStorybook(
workspaceRoot: string,
projectRoot: string,
options: ReactNativeStorybookOptions
) {
return new Promise((resolve, reject) => {
childProcess = fork(
join(
workspaceRoot,
'./node_modules/react-native-storybook-loader/out/rnstl-cli.js'
),
createStorybookOptions(options),
{
cwd: workspaceRoot,
}
);
// Ensure the child process is killed when the parent exits
process.on('exit', () => childProcess.kill());
process.on('SIGTERM', () => childProcess.kill());
childProcess.on('error', (err) => {
reject(err);
});
childProcess.on('exit', (code) => {
if (code === 0) {
resolve(code);
} else {
reject(code);
}
});
});
}
function createStorybookOptions(options) {
return Object.keys(options).reduce((acc, k) => {
const v = options[k];
if (typeof v === 'boolean') {
if (v === true) {
acc.push(`--${k}`);
}
} else {
acc.push(`--${k}`, options[k]);
}
return acc;
}, []);
}

View File

@ -19,6 +19,7 @@ module.exports = (async () => {
resolver: {
assetExts: assetExts.filter((ext) => ext !== 'svg'),
sourceExts: [...sourceExts, 'svg'],
resolverMainFields: ['sbmodern', 'browser', 'main'],
},
},
{

View File

@ -8,6 +8,7 @@
"react": "*",
"react-native": "*",
"react-native-config": "*",
"react-native-svg": "*"
"react-native-svg": "*",
"@react-native-async-storage/async-storage": "*"
}
}

View File

@ -26,7 +26,7 @@ import GitHub from './icons/github.svg';
import Terminal from './icons/terminal.svg';
import Heart from './icons/heart.svg';
const App = () => {
export const App = () => {
const [whatsNextYCoord, setWhatsNextYCoord] = useState<number>(0);
const scrollViewRef = useRef<null | ScrollView>(null);

View File

@ -0,0 +1,445 @@
import { getProjects, Tree, updateProjectConfiguration } from '@nrwl/devkit';
import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing';
import componentStoryGenerator from './component-story';
import { Linter } from '@nrwl/linter';
import { formatFile } from '../../utils/format-file';
import libraryGenerator from '../library/library';
import componentGenerator from '../component/component';
describe('react-native:component-story', () => {
let appTree: Tree;
let cmpPath = 'libs/test-ui-lib/src/lib/test-ui-lib/test-ui-lib.tsx';
let storyFilePath =
'libs/test-ui-lib/src/lib/test-ui-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.write(
cmpPath,
`export const add = (a: number, b: number) => a + b;`
);
});
it('should fail with a descriptive error message', async () => {
try {
await componentStoryGenerator(appTree, {
componentPath: 'lib/test-ui-lib/test-ui-lib.tsx',
project: 'test-ui-lib',
});
} catch (e) {
expect(e.message).toContain(
'Could not find any React Native component in file libs/test-ui-lib/src/lib/test-ui-lib/test-ui-lib.tsx'
);
}
});
});
describe('default component setup', () => {
beforeEach(async () => {
await componentStoryGenerator(appTree, {
componentPath: 'lib/test-ui-lib/test-ui-lib.tsx',
project: 'test-ui-lib',
});
});
it('should create the story file', () => {
expect(appTree.exists(storyFilePath)).toBeTruthy();
});
it('should properly set up the story', () => {
expect(
formatFile`${appTree.read(storyFilePath, 'utf-8').replace('·', '')}`
).toContain(formatFile`
import { storiesOf } from '@storybook/react-native';
import React from 'react';
import { TestUiLib, TestUiLibProps } from './test-ui-lib';
const props: TestUiLibProps = {};
storiesOf('TestUiLib', module)
.addDecorator((getStory) => <>{getStory()}</>)
.add('Primary', () => <TestUiLib {...props} />);
`);
});
});
describe('when using plain JS components', () => {
let storyFilePathPlain =
'libs/test-ui-lib/src/lib/test-ui-libplain.stories.jsx';
beforeEach(async () => {
appTree.write(
'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;
`
);
await componentStoryGenerator(appTree, {
componentPath: 'lib/test-ui-libplain.jsx',
project: 'test-ui-lib',
});
});
it('should create the story file', () => {
expect(appTree.exists(storyFilePathPlain)).toBeTruthy();
});
it('should properly set up the story', () => {
expect(formatFile`${appTree.read(storyFilePathPlain, 'utf-8')}`)
.toContain(formatFile`
import { storiesOf } from '@storybook/react-native';
import React from 'react';
import { Test } from './test-ui-libplain';
const props = {};
storiesOf('Test', module)
.addDecorator((getStory) => <>{getStory()}</>)
.add('Primary', () => <Test {...props} />);
`);
});
});
describe('component without any props defined', () => {
beforeEach(async () => {
appTree.write(
cmpPath,
`import React from 'react';
import { View, Text } from 'react-native';
export function Test() {
return (
<View>
<Text>Welcome to test!</Text>
</View>
);
}
export default Test;
`
);
await componentStoryGenerator(appTree, {
componentPath: 'lib/test-ui-lib/test-ui-lib.tsx',
project: 'test-ui-lib',
});
});
it('should create a story without controls', () => {
expect(formatFile`${appTree.read(storyFilePath, 'utf-8')}`)
.toContain(formatFile`
import { storiesOf } from '@storybook/react-native';
import React from 'react';
import { Test } from './test-ui-lib';
const props = {};
storiesOf('Test', module)
.addDecorator((getStory) => <>{getStory()}</>)
.add('Primary', () => <Test {...props} />);
`);
});
});
describe('component with props', () => {
beforeEach(async () => {
await componentStoryGenerator(appTree, {
componentPath: 'lib/test-ui-lib/test-ui-lib.tsx',
project: 'test-ui-lib',
});
});
it('should setup controls based on the component props', () => {
expect(
formatFile`${appTree.read(storyFilePath, 'utf-8').replace('·', '')}`
).toContain(formatFile`
import { storiesOf } from '@storybook/react-native';
import React from 'react';
import { TestUiLib, TestUiLibProps } from './test-ui-lib';
const props: TestUiLibProps = {};
storiesOf('TestUiLib', module)
.addDecorator((getStory) => <>{getStory()}</>)
.add('Primary', () => <TestUiLib {...props} />);
`);
});
});
describe('component with props and actions', () => {
beforeEach(async () => {
appTree.write(
cmpPath,
`import React from 'react';
export type ButtonStyle = 'default' | 'primary' | 'warning';
export interface TestProps {
name: string;
displayAge: boolean;
someAction: (e: unknown) => void;
style: ButtonStyle;
}
export const Test = (props: TestProps) => {
return (
<div>
<h1>Welcome to test component, {props.name}</h1>
<button onClick={props.someAction}>Click me!</button>
</div>
);
};
export default Test;
`
);
await componentStoryGenerator(appTree, {
componentPath: 'lib/test-ui-lib/test-ui-lib.tsx',
project: 'test-ui-lib',
});
});
it('should setup controls based on the component props', () => {
expect(
formatFile`${appTree.read(storyFilePath, 'utf-8').replace('·', '')}`
).toContain(formatFile`
import { action } from '@storybook/addon-actions';
import { storiesOf } from '@storybook/react-native';
import React from 'react';
import { Test, TestProps } from './test-ui-lib';
const actions = { someAction: action('someAction executed!') };
const props: TestProps = { name: '', displayAge: false };
storiesOf('Test', module)
.addDecorator((getStory) => <>{getStory()}</>)
.add('Primary', () => <Test {...props} {...actions} />);
`);
});
});
[
{
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
`,
},
{
name: 'direct export of component class new JSX transform',
src: `
export default class Test extends Component<TestProps> {
render() {
return <div><h1>Welcome to test component, {this.props.name}</h1></div>;
}
}
`,
},
{
name: 'component class & then default export new JSX transform',
src: `
class Test extends Component<TestProps> {
render() {
return <div><h1>Welcome to test component, {this.props.name}</h1></div>;
}
}
export default Test
`,
},
{
name: 'PureComponent class & then default export new JSX transform',
src: `
class Test extends PureComponent<TestProps> {
render() {
return <div><h1>Welcome to test component, {this.props.name}</h1></div>;
}
}
export default Test
`,
},
].forEach((config) => {
describe(`React Native component defined as:${config.name}`, () => {
beforeEach(async () => {
appTree.write(
cmpPath,
`import React from 'react';
export interface TestProps {
name: string;
displayAge: boolean;
}
${config.src}
`
);
await componentStoryGenerator(appTree, {
componentPath: 'lib/test-ui-lib/test-ui-lib.tsx',
project: 'test-ui-lib',
});
});
it('should properly setup the controls based on the component props', () => {
expect(
formatFile`${appTree.read(storyFilePath, 'utf-8').replace('·', '')}`
).toContain(formatFile`
import { storiesOf } from '@storybook/react-native';
import React from 'react';
import { Test, TestProps } from './test-ui-lib';
const props: TestProps = {
name: '',
displayAge: false,
};
storiesOf('Test', module)
.addDecorator((getStory) => <>{getStory()}</>)
.add('Primary', () => <Test {...props} />);
`);
});
});
});
});
describe('using eslint', () => {
beforeEach(async () => {
appTree = await createTestUILib('test-ui-lib', false);
await componentGenerator(appTree, {
name: 'test-ui-lib',
project: 'test-ui-lib',
export: true,
});
await componentStoryGenerator(appTree, {
componentPath: 'lib/test-ui-lib/test-ui-lib.tsx',
project: 'test-ui-lib',
});
});
it('should properly set up the story', () => {
expect(formatFile`${appTree.read(storyFilePath, 'utf-8')}`)
.toContain(formatFile`
import { storiesOf } from '@storybook/react-native';
import React from 'react';
import { TestUiLib, TestUiLibProps } from './test-ui-lib';
const props: TestUiLibProps = {};
storiesOf('TestUiLib', module)
.addDecorator((getStory) => <>{getStory()}</>)
.add('Primary', () => <TestUiLib {...props} />);
`);
});
});
});
export async function createTestUILib(
libName: string,
useEsLint = false
): Promise<Tree> {
let appTree = createTreeWithEmptyWorkspace();
appTree.write('.gitignore', '');
await libraryGenerator(appTree, {
name: libName,
linter: useEsLint ? Linter.EsLint : Linter.TsLint,
skipFormat: true,
skipTsConfig: false,
unitTestRunner: 'jest',
});
await componentGenerator(appTree, {
name: libName,
project: libName,
export: true,
});
if (useEsLint) {
const currentWorkspaceJson = getProjects(appTree);
const projectConfig = currentWorkspaceJson.get(libName);
projectConfig.targets.lint.options.linter = 'eslint';
updateProjectConfiguration(appTree, libName, projectConfig);
}
return appTree;
}

View File

@ -0,0 +1,142 @@
import {
convertNxGenerator,
formatFiles,
generateFiles,
getProjects,
joinPathFragments,
normalizePath,
Tree,
} from '@nrwl/devkit';
import * as ts from 'typescript';
import {
getComponentName,
getComponentPropsInterface,
} from '@nrwl/react/src/utils/ast-utils';
import { CreateComponentStoriesFileSchema } from './schema';
export function getArgsDefaultValue(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(
host: Tree,
{ project, componentPath }: CreateComponentStoriesFileSchema
) {
const proj = getProjects(host).get(project);
const sourceRoot = proj.sourceRoot;
const componentFilePath = joinPathFragments(sourceRoot, componentPath);
const componentDirectory = componentFilePath.replace(
componentFilePath.slice(componentFilePath.lastIndexOf('/')),
''
);
const isPlainJs =
componentFilePath.endsWith('.jsx') || componentFilePath.endsWith('.js');
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 = host.read(componentFilePath, 'utf-8');
if (contents === null) {
throw new Error(`Failed to read ${componentFilePath}`);
}
const sourceFile = ts.createSourceFile(
componentFilePath,
contents,
ts.ScriptTarget.Latest,
true
);
const cmpDeclaration = getComponentName(sourceFile);
if (!cmpDeclaration) {
throw new Error(
`Could not find any React Native component in file ${componentFilePath}`
);
}
const propsInterface = getComponentPropsInterface(sourceFile);
let propsTypeName: string = null;
let props: {
name: string;
defaultValue: any;
}[] = [];
let argTypes: {
name: string;
type: string;
actionText: string;
}[] = [];
if (propsInterface) {
propsTypeName = propsInterface.name.text;
props = propsInterface.members.map((member: ts.PropertySignature) => {
if (member.type.kind === ts.SyntaxKind.FunctionType) {
argTypes.push({
name: (member.name as ts.Identifier).text,
type: 'action',
actionText: `${(member.name as ts.Identifier).text} executed!`,
});
} else {
return {
name: (member.name as ts.Identifier).text,
defaultValue: getArgsDefaultValue(member.type.kind),
};
}
});
props = props.filter((p) => p && p.defaultValue !== undefined);
}
generateFiles(
host,
joinPathFragments(__dirname, './files'),
normalizePath(componentDirectory),
{
componentFileName: name,
propsTypeName,
props,
argTypes,
componentName: (cmpDeclaration as any).name.text,
isPlainJs,
fileExt,
hasActions: argTypes && argTypes.length,
}
);
}
export async function componentStoryGenerator(
host: Tree,
schema: CreateComponentStoriesFileSchema
) {
createComponentStoriesFile(host, schema);
await formatFiles(host);
}
export default componentStoryGenerator;
export const componentStorySchematic = convertNxGenerator(
componentStoryGenerator
);

View File

@ -0,0 +1,25 @@
<% if (hasActions) { %>
import { action } from '@storybook/addon-actions';
<% } %>
import { storiesOf } from '@storybook/react-native';
import React from 'react';
import {
<%= componentName %>
<% if ( propsTypeName ) { %>, <%= propsTypeName %> <% } %>
} from './<%= componentFileName %>';
<% if (hasActions) { %>
const actions = {<% for (let argType of argTypes) { %>
<%= argType.name %>: action('<%- argType.actionText %>'),
<% } %>};
<% } %>
const props <% if ( propsTypeName ) { %>:<%= propsTypeName %><% } %> = {<% for (let prop of props) { %>
<%= prop.name %>: <%- prop.defaultValue %>,
<% } %>};
storiesOf('<%= componentName %>', module)
.addDecorator((getStory) => <>{getStory()}</>)
.add('Primary', () => (
<<%= componentName %> {...props} <% if (hasActions) { %> {...actions} <% } %>/>
));

View File

@ -0,0 +1,4 @@
export interface CreateComponentStoriesFileSchema {
project: string;
componentPath: string;
}

View File

@ -0,0 +1,25 @@
{
"$schema": "http://json-schema.org/schema",
"cli": "nx",
"$id": "NxReactNativeComponentStory",
"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 the 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

@ -22,6 +22,7 @@ import {
metroReactNativeBabelPresetVersion,
metroVersion,
nxVersion,
reactNativeAsyncStorageVersion,
reactNativeCommunityCli,
reactNativeCommunityCliAndroid,
reactNativeCommunityCliIos,
@ -88,6 +89,8 @@ export function updateDependencies(host: Tree) {
'react-native-svg-transformer': reactNativeSvgTransformerVersion,
'react-native-svg': reactNativeSvgVersion,
'react-native-config': reactNativeConfigVersion,
'@react-native-async-storage/async-storage':
reactNativeAsyncStorageVersion,
...(isPnpm
? {
'metro-config': metroVersion, // metro-config is used by metro.config.js

View File

@ -0,0 +1,3 @@
export interface StorybookStoriesSchema {
project: string;
}

View File

@ -0,0 +1,18 @@
{
"$schema": "http://json-schema.org/schema",
"cli": "nx",
"$id": "NxReactNativeStorybookStories",
"type": "object",
"properties": {
"project": {
"type": "string",
"description": "Library or application name",
"$default": {
"$source": "projectName",
"index": 0
},
"x-prompt": "What's the name of the project for which you want to generate stories?"
}
},
"required": ["project"]
}

View File

@ -0,0 +1,66 @@
import { Tree } from '@nrwl/devkit';
import storiesGenerator from './stories';
import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing';
import applicationGenerator from '../application/application';
import { Linter } from '@nrwl/linter';
import { reactNativeComponentGenerator } from '../component/component';
describe('react:stories for applications', () => {
let appTree: Tree;
beforeEach(async () => {
appTree = await createTestUIApp('test-ui-app');
});
it('should create the stories', async () => {
await reactNativeComponentGenerator(appTree, {
name: 'another-cmp',
project: 'test-ui-app',
});
await storiesGenerator(appTree, {
project: 'test-ui-app',
});
expect(appTree.exists('apps/test-ui-app/src/app/App.tsx')).toBeTruthy();
expect(
appTree.exists('apps/test-ui-app/src/app/App.stories.tsx')
).toBeTruthy();
expect(
appTree.exists(
'apps/test-ui-app/src/app/another-cmp/another-cmp.stories.tsx'
)
).toBeTruthy();
});
it('should ignore files that do not contain components', async () => {
// create another component
appTree.write(
'apps/test-ui-app/src/app/some-utils.js',
`export const add = (a: number, b: number) => a + b;`
);
await storiesGenerator(appTree, {
project: 'test-ui-app',
});
// should just create the story and not error, even though there's a js file
// not containing any react component
expect(
appTree.exists('apps/test-ui-app/src/app/App.stories.tsx')
).toBeTruthy();
});
});
export async function createTestUIApp(libName: string): Promise<Tree> {
let appTree = createTreeWithEmptyWorkspace();
appTree.write('.gitignore', '');
await applicationGenerator(appTree, {
linter: Linter.EsLint,
skipFormat: false,
style: 'css',
unitTestRunner: 'none',
name: libName,
});
return appTree;
}

View File

@ -0,0 +1,86 @@
import { Tree } from '@nrwl/devkit';
import storiesGenerator from './stories';
import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing';
import applicationGenerator from '../application/application';
import { Linter } from '@nrwl/linter';
import libraryGenerator from '../library/library';
import reactNativeComponentGenerator from '../component/component';
describe('react-native:stories for libraries', () => {
let appTree: Tree;
beforeEach(async () => {
appTree = await createTestUILib('test-ui-lib');
});
it('should create the stories', async () => {
await reactNativeComponentGenerator(appTree, {
name: 'test-ui-lib',
project: 'test-ui-lib',
});
await reactNativeComponentGenerator(appTree, {
name: 'another-cmp',
project: 'test-ui-lib',
});
await storiesGenerator(appTree, {
project: 'test-ui-lib',
});
expect(
appTree.exists(
'libs/test-ui-lib/src/lib/test-ui-lib/test-ui-lib.stories.tsx'
)
).toBeTruthy();
expect(
appTree.exists(
'libs/test-ui-lib/src/lib/another-cmp/another-cmp.stories.tsx'
)
).toBeTruthy();
});
it('should ignore files that do not contain components', async () => {
await reactNativeComponentGenerator(appTree, {
name: 'test-ui-lib',
project: 'test-ui-lib',
});
// create another component
appTree.write(
'libs/test-ui-lib/src/lib/some-utils.ts',
`export const add = (a: number, b: number) => a + b;`
);
await storiesGenerator(appTree, {
project: 'test-ui-lib',
});
// should just create the story and not error, even though there's a js file
// not containing any react component
expect(
appTree.exists(
'libs/test-ui-lib/src/lib/test-ui-lib/test-ui-lib.stories.tsx'
)
).toBeTruthy();
});
});
export async function createTestUILib(libName: string): Promise<Tree> {
let appTree = createTreeWithEmptyWorkspace();
appTree.write('.gitignore', '');
await libraryGenerator(appTree, {
linter: Linter.EsLint,
skipFormat: true,
skipTsConfig: false,
unitTestRunner: 'none',
name: libName,
});
await applicationGenerator(appTree, {
e2eTestRunner: 'none',
linter: Linter.EsLint,
skipFormat: false,
unitTestRunner: 'none',
name: `${libName}-e2e`,
});
return appTree;
}

View File

@ -0,0 +1,94 @@
import { getComponentName } from '@nrwl/react/src/utils/ast-utils';
import * as ts from 'typescript';
import {
convertNxGenerator,
getProjects,
joinPathFragments,
ProjectType,
Tree,
visitNotIgnoredFiles,
} from '@nrwl/devkit';
import { join } from 'path';
import componentStoryGenerator from '../component-story/component-story';
import { StorybookStoriesSchema } from './schema';
export function projectRootPath(
tree: Tree,
sourceRoot: string,
projectType: ProjectType
): string {
let projectDir = '';
if (projectType === 'application') {
// apps/test-app/src/app
projectDir = 'app';
} else if (projectType == 'library') {
// libs/test-lib/src/lib
projectDir = 'lib';
}
return joinPathFragments(sourceRoot, projectDir);
}
function containsComponentDeclaration(
tree: Tree,
componentPath: string
): boolean {
const contents = tree.read(componentPath, 'utf-8');
if (contents === null) {
throw new Error(`Failed to read ${componentPath}`);
}
const sourceFile = ts.createSourceFile(
componentPath,
contents,
ts.ScriptTarget.Latest,
true
);
return !!getComponentName(sourceFile);
}
export async function createAllStories(tree: Tree, projectName: string) {
const projects = getProjects(tree);
const project = projects.get(projectName);
const { sourceRoot, projectType } = project;
const projectPath = projectRootPath(tree, sourceRoot, projectType);
let componentPaths: string[] = [];
visitNotIgnoredFiles(tree, projectPath, (path) => {
if (
(path.endsWith('.tsx') && !path.endsWith('.spec.tsx')) ||
(path.endsWith('.js') && !path.endsWith('.spec.js')) ||
(path.endsWith('.jsx') && !path.endsWith('.spec.jsx'))
) {
componentPaths.push(path);
}
});
await Promise.all(
componentPaths.map(async (componentPath) => {
const relativeCmpDir = componentPath.replace(join(sourceRoot, '/'), '');
if (!containsComponentDeclaration(tree, componentPath)) {
return;
}
await componentStoryGenerator(tree, {
componentPath: relativeCmpDir,
project: projectName,
});
})
);
}
export async function storiesGenerator(
host: Tree,
schema: StorybookStoriesSchema
) {
await createAllStories(host, schema.project);
}
export default storiesGenerator;
export const storiesSchematic = convertNxGenerator(storiesGenerator);

View File

@ -0,0 +1,142 @@
import * as fileUtils from '@nrwl/workspace/src/core/file-utils';
import { Tree } from '@nrwl/devkit';
import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing';
import { Linter } from '@nrwl/linter';
import { logger } from '@nrwl/devkit';
import libraryGenerator from '../library/library';
import applicationGenerator from '../application/application';
import componentGenerator from '../component/component';
import storybookConfigurationGenerator from './configuration';
describe('react-native:storybook-configuration', () => {
let appTree;
beforeEach(async () => {
jest.spyOn(fileUtils, 'readPackageJson').mockReturnValue({
devDependencies: {
'@storybook/addon-essentials': '*',
'@storybook/react-native': '*',
'@storybook/addon-ondevice-actions': '*',
'@storybook/addon-ondevice-knobs': '*',
},
});
jest.spyOn(logger, 'warn').mockImplementation(() => {});
jest.spyOn(logger, 'debug').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('should generate files for an app', () => {
it('should configure everything at once', async () => {
appTree = await createTestUILib('test-ui-lib');
appTree.write('.gitignore', '');
await storybookConfigurationGenerator(appTree, {
name: 'test-ui-lib',
standaloneConfig: false,
});
expect(
appTree.exists('libs/test-ui-lib/.storybook/main.js')
).toBeTruthy();
expect(
appTree.exists('libs/test-ui-lib/.storybook/tsconfig.json')
).toBeTruthy();
});
it('should generate stories for components', async () => {
appTree = await createTestUILib('test-ui-lib');
await componentGenerator(appTree, {
name: 'test-ui-lib',
project: 'test-ui-lib',
});
await storybookConfigurationGenerator(appTree, {
name: 'test-ui-lib',
generateStories: true,
standaloneConfig: false,
});
expect(
appTree.exists(
'libs/test-ui-lib/src/lib/test-ui-lib/test-ui-lib.stories.tsx'
)
).toBeTruthy();
});
});
describe('should generate files for lib', () => {
it('should configure everything at once', async () => {
appTree = await createTestAppLib('test-ui-app');
await storybookConfigurationGenerator(appTree, {
name: 'test-ui-app',
standaloneConfig: false,
});
expect(
appTree.exists('apps/test-ui-app/.storybook/main.js')
).toBeTruthy();
expect(
appTree.exists('apps/test-ui-app/.storybook/tsconfig.json')
).toBeTruthy();
});
it('should generate stories for components', async () => {
appTree = await createTestAppLib('test-ui-app');
await storybookConfigurationGenerator(appTree, {
name: 'test-ui-app',
generateStories: true,
standaloneConfig: false,
});
// Currently the auto-generate stories feature only picks up components under the 'lib' directory.
// In our 'createTestAppLib' function, we call @nrwl/react-native:component to generate a component
// under the specified 'lib' directory
expect(
appTree.exists(
'apps/test-ui-app/src/app/my-component/my-component.stories.tsx'
)
).toBeTruthy();
});
});
});
export async function createTestUILib(libName: string): Promise<Tree> {
let appTree = createTreeWithEmptyWorkspace();
await libraryGenerator(appTree, {
linter: Linter.EsLint,
skipFormat: true,
skipTsConfig: false,
unitTestRunner: 'none',
name: libName,
});
return appTree;
}
export async function createTestAppLib(
libName: string,
plainJS = false
): Promise<Tree> {
let appTree = createTreeWithEmptyWorkspace();
await applicationGenerator(appTree, {
e2eTestRunner: 'none',
linter: Linter.EsLint,
skipFormat: false,
style: 'css',
unitTestRunner: 'none',
name: libName,
js: plainJS,
});
await componentGenerator(appTree, {
name: 'my-component',
project: libName,
directory: 'app',
});
return appTree;
}

View File

@ -0,0 +1,64 @@
import {
convertNxGenerator,
GeneratorCallback,
readProjectConfiguration,
Tree,
updateProjectConfiguration,
} from '@nrwl/devkit';
import { configurationGenerator } from '@nrwl/storybook';
import storiesGenerator from '../stories/stories';
import { createStorybookFiles } from './lib/create-storybook-files';
import { replaceAppImportWithStorybookToggle } from './lib/replace-app-import-with-storybook-toggle';
import { StorybookConfigureSchema } from './schema';
async function generateStories(host: Tree, schema: StorybookConfigureSchema) {
await storiesGenerator(host, {
project: schema.name,
});
}
export async function storybookConfigurationGenerator(
host: Tree,
schema: StorybookConfigureSchema
): Promise<GeneratorCallback> {
const installTask = await configurationGenerator(host, {
name: schema.name,
uiFramework: '@storybook/react-native',
configureCypress: false,
js: false,
linter: schema.linter,
standaloneConfig: schema.standaloneConfig,
});
addStorybookTask(host, schema.name);
createStorybookFiles(host, schema);
replaceAppImportWithStorybookToggle(host, schema);
if (schema.generateStories) {
await generateStories(host, schema);
}
return installTask;
}
function addStorybookTask(host: Tree, projectName: string) {
const projectConfig = readProjectConfiguration(host, projectName);
projectConfig.targets['storybook'] = {
executor: '@nrwl/react-native:storybook',
options: {
searchDir: projectConfig.root,
outputFile: './.storybook/story-loader.js',
pattern: '**/*.stories.@(js|jsx|ts|tsx|md)',
silent: false,
},
};
updateProjectConfiguration(host, projectName, projectConfig);
}
export default storybookConfigurationGenerator;
export const storybookConfigurationSchematic = convertNxGenerator(
storybookConfigurationGenerator
);

View File

@ -0,0 +1,9 @@
import { configure, getStorybookUI } from '@storybook/react-native';
import { loadStories } from '../../../.storybook/story-loader';
configure(loadStories(), module, false);
const StorybookUIRoot = getStorybookUI({});
export default StorybookUIRoot;

View File

@ -0,0 +1,114 @@
/**
* Toggle inspired from https://github.com/infinitered/ignite/blob/master/boilerplate/storybook/toggle-storybook.tsx
*/
import React, { useState, useEffect, useRef } from 'react';
import { DevSettings } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import AppRoot from '../src/app/App';
export const DEFAULT_REACTOTRON_WS_URI = 'ws://localhost:9090';
/**
* Loads a string from storage.
*
* @param key The key to fetch.
*/
async function loadString(key: string): Promise<string | null> {
try {
return await AsyncStorage.getItem(key);
} catch {
// not sure why this would fail... even reading the RN docs I'm unclear
return null;
}
}
/**
* Saves a string to storage.
*
* @param key The key to fetch.
* @param value The value to store.
*/
async function saveString(key: string, value: string): Promise<boolean> {
try {
await AsyncStorage.setItem(key, value);
return true;
} catch {
return false;
}
}
/**
* Toggle Storybook mode, in __DEV__ mode only.
*
* In non-__DEV__ mode, or when Storybook isn't toggled on,
* renders its children.
*
* The mode flag is persisted in async storage, which means it
* persists across reloads/restarts - this is handy when developing
* new components in Storybook.
*/
function ToggleStorybook(props) {
const [showStorybook, setShowStorybook] = useState(false);
const [StorybookUIRoot, setStorybookUIRoot] = useState(null);
const ws = useRef(new WebSocket(DEFAULT_REACTOTRON_WS_URI));
useEffect(() => {
if (!__DEV__) {
return undefined;
}
// Load the setting from storage if it's there
loadString('devStorybook').then((storedSetting) => {
// Set the initial value
setShowStorybook(storedSetting === 'on');
if (DevSettings) {
// Add our toggle command to the menu
DevSettings.addMenuItem('Toggle Storybook', () => {
setShowStorybook((show) => {
// On toggle, flip the current value
show = !show;
// Write it back to storage
saveString('devStorybook', show ? 'on' : 'off');
// Return it to change the local state
return show;
});
});
}
// Load the storybook UI once
// eslint-disable-next-line @typescript-eslint/no-var-requires
setStorybookUIRoot(() => require('./storybook.ts').default);
// Behave as Reactotron.storybookSwitcher(), not a HOC way.
ws.current.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.type === 'storybook') {
saveString('devStorybook', data.payload ? 'on' : 'off');
setShowStorybook(data.payload);
}
};
ws.current.onerror = (e) => {
setShowStorybook(storedSetting === 'on');
};
});
}, []);
if (showStorybook) {
return StorybookUIRoot ? <StorybookUIRoot /> : null;
} else {
return props.children;
}
}
export default () => {
return (
<ToggleStorybook>
<AppRoot />
</ToggleStorybook>
);
};

View File

@ -0,0 +1,13 @@
// Auto-generated file created by react-native-storybook-loader
// Do not edit.
//
// https://github.com/elderfo/react-native-storybook-loader.git
function loadStories() {}
const stories = [];
module.exports = {
loadStories,
stories,
};

View File

@ -0,0 +1,77 @@
import {
generateFiles,
logger,
offsetFromRoot,
readProjectConfiguration,
toJS,
Tree,
} from '@nrwl/devkit';
import { join } from 'path';
import * as chalk from 'chalk';
import { StorybookConfigureSchema } from '../schema';
/**
* This function generate ./storybook under project root.
*/
export async function createStorybookFiles(
host: Tree,
schema: StorybookConfigureSchema
) {
const { root, projectType, targets, sourceRoot } = readProjectConfiguration(
host,
schema.name
);
// do not proceed if not a react native project
if (targets?.start?.executor !== '@nrwl/react-native:start') {
return;
}
const storybookUIFileName = schema.js ? 'storybook.js' : 'storybook.ts';
const storybookUIFilePath = join(root, `./${storybookUIFileName}`);
if (host.exists(storybookUIFilePath)) {
logger.warn(
`${storybookUIFileName} file already exists for React Native ${projectType} ${schema.name}! Skipping generating this file.`
);
return;
}
if (projectType !== 'application') {
logger.info(
`${chalk.bold.cyan(
'info'
)} To see your Storybook stories on the device, you should start your mobile app for the <platform> of your choice (typically ios or android).`
);
}
const projectDirectory = projectType === 'application' ? 'app' : 'lib';
logger.debug(`Adding storybook file to React Native app ${projectDirectory}`);
// copy files to app's .storybook folder
generateFiles(
host,
join(__dirname, '../files/app'),
join(root, '.storybook'),
{
tmpl: '',
offsetFromRoot: offsetFromRoot(sourceRoot),
projectType: projectDirectory,
}
);
// copy files to workspace root's .storybook folder
generateFiles(
host,
join(__dirname, '../files/root'),
join(root, offsetFromRoot(root), '.storybook'),
{
tmpl: '',
}
);
if (schema.js) {
toJS(host);
}
}

View File

@ -0,0 +1,65 @@
import { addProjectConfiguration, Tree } from '@nrwl/devkit';
import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing';
import { formatFile } from '../../../utils/format-file';
import { replaceAppImportWithStorybookToggle } from './replace-app-import-with-storybook-toggle';
describe('replaceAppImportWithStorybookToggle', () => {
let tree: Tree;
beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
addProjectConfiguration(tree, 'products', {
root: 'apps/products',
sourceRoot: 'apps/products/src',
});
});
it('should update the main file with import from storybook', async () => {
tree.write(
'/apps/products/src/main.tsx',
formatFile`import { AppRegistry } from 'react-native';
import App from './app/App';
AppRegistry.registerComponent('main', () => App);
`
);
replaceAppImportWithStorybookToggle(tree, {
name: 'products',
js: false,
});
const mainFile = tree.read('apps/products/src/main.tsx', 'utf-8');
expect(formatFile`${mainFile}`).toEqual(
formatFile`import { AppRegistry } from 'react-native';
import App from '../.storybook/toggle-storybook';
AppRegistry.registerComponent('main', () => App);`
);
});
it('should not update the main file if import is already updated', async () => {
tree.write(
'/apps/products/src/main.tsx',
formatFile`import { AppRegistry } from 'react-native';
import App from './app/App';
AppRegistry.registerComponent('main', () => App);
`
);
replaceAppImportWithStorybookToggle(tree, {
name: 'products',
js: false,
});
const mainFile = tree.read('apps/products/src/main.tsx', 'utf-8');
expect(formatFile`${mainFile}`).toEqual(
formatFile`import { AppRegistry } from 'react-native';
import App from '../.storybook/toggle-storybook';
AppRegistry.registerComponent('main', () => App);`
);
});
});

View File

@ -0,0 +1,44 @@
import {
logger,
readProjectConfiguration,
stripIndents,
Tree,
} from '@nrwl/devkit';
import { join } from 'path';
import { StorybookConfigureSchema } from '../schema';
/**
* To replace the import statement for storybook.
* Need to import app with storybook toggle from .storybook/toggle-storybook
*/
export function replaceAppImportWithStorybookToggle(
host: Tree,
schema: StorybookConfigureSchema
) {
const { root, sourceRoot } = readProjectConfiguration(host, schema.name);
const mainFilePath = join(sourceRoot, schema.js ? 'main.js' : 'main.tsx');
const appImportImport = `import App from './app/App';`;
const storybookeToggleImport = `import App from '../.storybook/toggle-storybook';`;
try {
logger.debug(`Updating import for ${mainFilePath}`);
const contents = host.read(mainFilePath, 'utf-8');
if (
!contents.includes(appImportImport) ||
contents.includes(storybookeToggleImport)
) {
logger.warn(stripIndents`${mainFilePath} is already udpated.`);
return;
}
host.write(
mainFilePath,
contents.replace(appImportImport, storybookeToggleImport)
);
} catch {
logger.warn(
stripIndents`Unable to update import in ${mainFilePath} for project ${root}.`
);
}
}

View File

@ -0,0 +1,9 @@
import { Linter } from '@nrwl/linter';
export interface StorybookConfigureSchema {
name: string;
generateStories?: boolean;
js?: boolean;
linter?: Linter;
standaloneConfig?: boolean;
}

View File

@ -0,0 +1,38 @@
{
"$schema": "http://json-schema.org/schema",
"cli": "nx",
"$id": "NxReactNativeStorybookConfigure",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Project name",
"$default": {
"$source": "argv",
"index": 0
}
},
"generateStories": {
"type": "boolean",
"description": "Automatically generate *.stories.ts files for components declared in this project?",
"x-prompt": "Automatically generate *.stories.ts files for components declared in this project?",
"default": true
},
"js": {
"type": "boolean",
"description": "Generate JavaScript files rather than TypeScript files.",
"default": false
},
"linter": {
"description": "The tool to use for running lint checks.",
"type": "string",
"enum": ["eslint", "tslint"],
"default": "eslint"
},
"standaloneConfig": {
"description": "Split the project configuration into <projectRoot>/project.json rather than including it inside workspace.json",
"type": "boolean"
}
},
"required": ["name"]
}

View File

@ -0,0 +1,16 @@
import { stripIndents } from '@nrwl/devkit';
import { format } from 'prettier';
export function formatFile(content, ...values) {
return format(
stripIndents(content, values)
.split('\n')
.map((line) => line.trim())
.join('')
.trim(),
{
singleQuote: true,
parser: 'typescript',
}
);
}

View File

@ -10,6 +10,7 @@ export const reactNativeCommunityCliIos = '6.2.0';
export const reactNativeCommunityCliAndroid = '6.3.0';
export const reactNativeConfigVersion = '1.4.5';
export const reactNativeAsyncStorageVersion = '1.15.17';
export const metroReactNativeBabelPresetVersion = '0.66.2';

View File

@ -44,7 +44,7 @@
"storybook-configuration": {
"factory": "./src/generators/storybook-configuration/configuration#storybookConfigurationSchematic",
"schema": "./src/generators/storybook-configuration/schema.json",
"description": "Set up storybook for a react library",
"description": "Set up storybook for a react app or library",
"hidden": false
},
@ -65,7 +65,7 @@
"stories": {
"factory": "./src/generators/stories/stories#storiesSchematic",
"schema": "./src/generators/stories/schema.json",
"description": "Create stories/specs for all components declared in a library",
"description": "Create stories/specs for all components declared in an app or library",
"hidden": false
},
@ -125,7 +125,7 @@
"storybook-configuration": {
"factory": "./src/generators/storybook-configuration/configuration#storybookConfigurationGenerator",
"schema": "./src/generators/storybook-configuration/schema.json",
"description": "Set up storybook for a react library",
"description": "Set up storybook for a react app or library",
"hidden": false
},
@ -146,7 +146,7 @@
"stories": {
"factory": "./src/generators/stories/stories#storiesGenerator",
"schema": "./src/generators/stories/schema.json",
"description": "Create stories/specs for all components declared in a library",
"description": "Create stories/specs for all components declared in an app or library",
"hidden": false
},

View File

@ -41,9 +41,6 @@ export function createComponentStoriesFile(
const proj = getProjects(host).get(project);
const sourceRoot = proj.sourceRoot;
// TODO: Remove this entirely, given we don't support TSLint with React?
const usesEsLint = true;
const componentFilePath = joinPathFragments(sourceRoot, componentPath);
const componentDirectory = componentFilePath.replace(
@ -132,7 +129,6 @@ export function createComponentStoriesFile(
componentName: (cmpDeclaration as any).name.text,
isPlainJs,
fileExt,
usesEsLint,
}
);
}

View File

@ -13,7 +13,8 @@ export interface CommonNxStorybookConfig {
| '@storybook/web-components'
| '@storybook/vue'
| '@storybook/vue3'
| '@storybook/svelte';
| '@storybook/svelte'
| '@storybook/react-native';
projectBuildConfig?: string;
config: StorybookConfig;
}

View File

@ -7,7 +7,6 @@ import {
resolveCommonStorybookOptionMapper,
runStorybookSetupCheck,
} from '../utils';
export interface StorybookExecutorOptions extends CommonNxStorybookConfig {
host?: string;
port?: number;
@ -23,7 +22,7 @@ export interface StorybookExecutorOptions extends CommonNxStorybookConfig {
export default async function* storybookExecutor(
options: StorybookExecutorOptions,
context: ExecutorContext
) {
): AsyncGenerator<{ success: boolean }> {
let frameworkPath = getStorybookFrameworkPath(options.uiFramework);
const frameworkOptions = (await import(frameworkPath)).default;

View File

@ -82,7 +82,9 @@ export async function configurationGenerator(
return runTasksInSerial(...tasks);
}
function normalizeSchema(schema: StorybookConfigureSchema) {
function normalizeSchema(
schema: StorybookConfigureSchema
): StorybookConfigureSchema {
const defaults = {
configureCypress: true,
linter: Linter.TsLint,
@ -209,14 +211,19 @@ function configureTsProjectConfig(
tsConfigContent = readJson<TsConfig>(tree, tsConfigPath);
}
tsConfigContent.exclude = [
...(tsConfigContent.exclude || []),
'**/*.stories.ts',
'**/*.stories.js',
...(isFramework('react', schema)
? ['**/*.stories.jsx', '**/*.stories.tsx']
: []),
];
if (
!tsConfigContent.exclude.includes('**/*.stories.ts') &&
!tsConfigContent.exclude.includes('**/*.stories.js')
) {
tsConfigContent.exclude = [
...(tsConfigContent.exclude || []),
'**/*.stories.ts',
'**/*.stories.js',
...(isFramework('react', schema) || isFramework('react-native', schema)
? ['**/*.stories.jsx', '**/*.stories.tsx']
: []),
];
}
writeJson(tree, tsConfigPath, tsConfigContent);
}
@ -231,12 +238,18 @@ function configureTsSolutionConfig(
const tsConfigPath = join(root, 'tsconfig.json');
const tsConfigContent = readJson<TsConfig>(tree, tsConfigPath);
tsConfigContent.references = [
...(tsConfigContent.references || []),
{
path: './.storybook/tsconfig.json',
},
];
if (
!tsConfigContent.references
.map((reference) => reference.path)
.includes('./.storybook/tsconfig.json')
) {
tsConfigContent.references = [
...(tsConfigContent.references || []),
{
path: './.storybook/tsconfig.json',
},
];
}
writeJson(tree, tsConfigPath, tsConfigContent);
}
@ -305,6 +318,9 @@ function addStorybookTask(
uiFramework: string,
buildTargetForAngularProjects: string
) {
if (uiFramework === '@storybook/react-native') {
return;
}
const projectConfig = readProjectConfiguration(tree, projectName);
projectConfig.targets['storybook'] = {
executor: '@nrwl/storybook:storybook',

View File

@ -11,7 +11,7 @@ module.exports = {
'../src/<%= projectType %>/**/*.stories.mdx',
'../src/<%= projectType %>/**/*.stories.@(js|jsx|ts|tsx)'
],
addons: [...rootMain.addons <% if(uiFramework === '@storybook/react') { %>, '@nrwl/react/plugins/storybook' <% } %>],
addons: [...rootMain.addons <% if(uiFramework === '@storybook/react') { %>, '@nrwl/react/plugins/storybook' <% } %><% if(uiFramework === '@storybook/react-native') { %>, '@storybook/addon-ondevice-actions', '@storybook/addon-ondevice-backgrounds', '@storybook/addon-ondevice-controls', '@storybook/addon-ondevice-notes' <% } %>],
webpackFinal: async (config, { configType }) => {
// apply any global webpack configs that might have been specified in .storybook/main.js
if (rootMain.webpackFinal) {

View File

@ -10,5 +10,5 @@
"<%= offsetFromRoot %>../node_modules/@nrwl/react/typings/image.d.ts"
],<% } %>
"exclude": ["../**/*.spec.ts" <% if(uiFramework === '@storybook/react') { %>, "../**/*.spec.js", "../**/*.spec.tsx", "../**/*.spec.jsx"<% } %>],
"include": ["../src/**/*", "*.js"]
"include": ["../src/**/*", "*.js" <% if(uiFramework === '@storybook/react-native') { %>, "*.ts", "*.tsx"<% } %>]
}

View File

@ -2,7 +2,10 @@ import { Linter } from '@nrwl/linter';
export interface StorybookConfigureSchema {
name: string;
uiFramework: '@storybook/angular' | '@storybook/react';
uiFramework:
| '@storybook/angular'
| '@storybook/react'
| '@storybook/react-native';
configureCypress?: boolean;
linter?: Linter;
js?: boolean;

View File

@ -13,6 +13,8 @@ import {
babelLoaderVersion,
babelPresetTypescriptVersion,
nxVersion,
reactNativeStorybookLoader,
storybookReactNativeVersion,
storybookVersion,
svgrVersion,
urlLoaderVersion,
@ -100,6 +102,7 @@ function checkDependenciesInstalled(host: Tree, schema: Schema) {
devDependencies['@storybook/manager-webpack5'] = storybookVersion;
}
}
if (isFramework('html', schema)) {
devDependencies['@storybook/html'] = storybookVersion;
}
@ -120,6 +123,55 @@ function checkDependenciesInstalled(host: Tree, schema: Schema) {
devDependencies['@storybook/svelte'] = storybookVersion;
}
if (isFramework('react-native', schema)) {
if (
!packageJson.dependencies['@storybook/react-native'] &&
!packageJson.devDependencies['@storybook/react-native']
) {
devDependencies['@storybook/react-native'] = storybookReactNativeVersion;
}
if (
!packageJson.dependencies['@storybook/addon-ondevice-actions'] &&
!packageJson.devDependencies['@storybook/addon-ondevice-actions']
) {
devDependencies['@storybook/addon-ondevice-actions'] =
storybookReactNativeVersion;
}
if (
!packageJson.dependencies['@storybook/addon-ondevice-backgrounds'] &&
!packageJson.devDependencies['@storybook/addon-ondevice-backgrounds']
) {
devDependencies['@storybook/addon-ondevice-backgrounds'] =
storybookReactNativeVersion;
}
if (
!packageJson.dependencies['@storybook/addon-ondevice-controls'] &&
!packageJson.devDependencies['@storybook/addon-ondevice-controls']
) {
devDependencies['@storybook/addon-ondevice-controls'] =
storybookReactNativeVersion;
}
if (
!packageJson.dependencies['@storybook/addon-ondevice-notes'] &&
!packageJson.devDependencies['@storybook/addon-ondevice-notes']
) {
devDependencies['@storybook/addon-ondevice-notes'] =
storybookReactNativeVersion;
}
if (
!packageJson.dependencies['react-native-storybook-loader'] &&
!packageJson.devDependencies['react-native-storybook-loader']
) {
devDependencies['react-native-storybook-loader'] =
reactNativeStorybookLoader;
}
}
return addDependenciesToPackageJson(host, dependencies, devDependencies);
}

View File

@ -6,5 +6,6 @@ export interface Schema {
| '@storybook/web-components'
| '@storybook/vue'
| '@storybook/vue3'
| '@storybook/svelte';
| '@storybook/svelte'
| '@storybook/react-native';
}

View File

@ -14,7 +14,8 @@
"@storybook/web-components",
"@storybook/vue",
"@storybook/vue3",
"@storybook/svelte"
"@storybook/svelte",
"@storybook/react-native"
],
"x-prompt": "What UI framework plugin should storybook use?"
}

View File

@ -131,11 +131,19 @@ function maybeUpdateVersion(tree: Tree): GeneratorCallback {
const allStorybookPackagesInDependencies = Object.keys(
json.dependencies
).filter((packageName: string) => packageName.startsWith('@storybook/'));
).filter(
(packageName: string) =>
packageName.startsWith('@storybook/') &&
!packageName.includes('@storybook/react-native')
);
const allStorybookPackagesInDevDependencies = Object.keys(
json.devDependencies
).filter((packageName: string) => packageName.startsWith('@storybook/'));
).filter(
(packageName: string) =>
packageName.startsWith('@storybook/') &&
!packageName.includes('@storybook/react-native')
);
const storybookPackages = [
...allStorybookPackagesInDependencies,

View File

@ -22,6 +22,7 @@ export const Constants = {
vue: '@storybook/vue',
vue3: '@storybook/vue3',
svelte: '@storybook/svelte',
'react-native': '@storybook/react-native',
} as const,
};
type Constants = typeof Constants;
@ -63,6 +64,13 @@ export function isFramework(
return true;
}
if (
type === 'react-native' &&
schema.uiFramework === '@storybook/react-native'
) {
return true;
}
return false;
}
@ -103,6 +111,10 @@ function determineStorybookWorkspaceVersion(packageJsonContents) {
workspaceStorybookVersion =
packageJsonContents['devDependencies']['@storybook/core'];
}
if (packageJsonContents['devDependencies']['@storybook/react-native']) {
workspaceStorybookVersion =
packageJsonContents['dependencies']['@storybook/react-native'];
}
}
if (packageJsonContents && packageJsonContents['dependencies']) {
@ -118,6 +130,10 @@ function determineStorybookWorkspaceVersion(packageJsonContents) {
workspaceStorybookVersion =
packageJsonContents['dependencies']['@storybook/core'];
}
if (packageJsonContents['dependencies']['@storybook/react-native']) {
workspaceStorybookVersion =
packageJsonContents['dependencies']['@storybook/react-native'];
}
}
return workspaceStorybookVersion;

View File

@ -5,3 +5,6 @@ export const babelLoaderVersion = '8.1.0';
export const babelPresetTypescriptVersion = '7.12.13';
export const svgrVersion = '^5.4.0';
export const urlLoaderVersion = '^3.0.0';
export const storybookReactNativeVersion = '6.0.1-alpha.7';
export const reactNativeStorybookLoader = '^2.0.5';

View File

@ -67,6 +67,7 @@
"@nrwl/react-native": ["./packages/react-native"],
"@nrwl/react/*": ["./packages/react/*"],
"@nrwl/storybook": ["./packages/storybook"],
"@nrwl/storybook/*": ["./packages/storybook/*"],
"@nrwl/tao": ["./packages/tao"],
"@nrwl/tao/*": ["./packages/tao/*"],
"@nrwl/typedoc-theme": ["/typedoc-theme/src/index.ts"],