feat(react-native): add storybook to react native (#8886)
This commit is contained in:
parent
b1e52b67f2
commit
19efdfc938
42
docs/generated/api-react-native/executors/storybook.md
Normal file
42
docs/generated/api-react-native/executors/storybook.md
Normal 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.
|
||||
@ -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.
|
||||
36
docs/generated/api-react-native/generators/stories.md
Normal file
36
docs/generated/api-react-native/generators/stories.md
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
"@nrwl/devkit": "*",
|
||||
"@nrwl/jest": "*",
|
||||
"@nrwl/linter": "*",
|
||||
"@nrwl/storybook": "*",
|
||||
"@nrwl/react": "*",
|
||||
"@nrwl/workspace": "*",
|
||||
"chalk": "^4.1.0",
|
||||
|
||||
5
packages/react-native/src/executors/storybook/compat.ts
Normal file
5
packages/react-native/src/executors/storybook/compat.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { convertNxExecutor } from '@nrwl/devkit';
|
||||
|
||||
import storybookExecutor from './storybook.impl';
|
||||
|
||||
export default convertNxExecutor(storybookExecutor);
|
||||
7
packages/react-native/src/executors/storybook/schema.d.ts
vendored
Normal file
7
packages/react-native/src/executors/storybook/schema.d.ts
vendored
Normal 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;
|
||||
}
|
||||
28
packages/react-native/src/executors/storybook/schema.json
Normal file
28
packages/react-native/src/executors/storybook/schema.json
Normal 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"]
|
||||
}
|
||||
@ -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;
|
||||
}, []);
|
||||
}
|
||||
@ -19,6 +19,7 @@ module.exports = (async () => {
|
||||
resolver: {
|
||||
assetExts: assetExts.filter((ext) => ext !== 'svg'),
|
||||
sourceExts: [...sourceExts, 'svg'],
|
||||
resolverMainFields: ['sbmodern', 'browser', 'main'],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
"react": "*",
|
||||
"react-native": "*",
|
||||
"react-native-config": "*",
|
||||
"react-native-svg": "*"
|
||||
"react-native-svg": "*",
|
||||
"@react-native-async-storage/async-storage": "*"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
@ -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} <% } %>/>
|
||||
));
|
||||
4
packages/react-native/src/generators/component-story/schema.d.ts
vendored
Normal file
4
packages/react-native/src/generators/component-story/schema.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
export interface CreateComponentStoriesFileSchema {
|
||||
project: string;
|
||||
componentPath: string;
|
||||
}
|
||||
@ -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"]
|
||||
}
|
||||
@ -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
|
||||
|
||||
3
packages/react-native/src/generators/stories/schema.d.ts
vendored
Normal file
3
packages/react-native/src/generators/stories/schema.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
export interface StorybookStoriesSchema {
|
||||
project: string;
|
||||
}
|
||||
18
packages/react-native/src/generators/stories/schema.json
Normal file
18
packages/react-native/src/generators/stories/schema.json
Normal 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"]
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
94
packages/react-native/src/generators/stories/stories.ts
Normal file
94
packages/react-native/src/generators/stories/stories.ts
Normal 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);
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);`
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
9
packages/react-native/src/generators/storybook-configuration/schema.d.ts
vendored
Normal file
9
packages/react-native/src/generators/storybook-configuration/schema.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
import { Linter } from '@nrwl/linter';
|
||||
|
||||
export interface StorybookConfigureSchema {
|
||||
name: string;
|
||||
generateStories?: boolean;
|
||||
js?: boolean;
|
||||
linter?: Linter;
|
||||
standaloneConfig?: boolean;
|
||||
}
|
||||
@ -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"]
|
||||
}
|
||||
16
packages/react-native/src/utils/format-file.ts
Normal file
16
packages/react-native/src/utils/format-file.ts
Normal 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',
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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
|
||||
},
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"<% } %>]
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -6,5 +6,6 @@ export interface Schema {
|
||||
| '@storybook/web-components'
|
||||
| '@storybook/vue'
|
||||
| '@storybook/vue3'
|
||||
| '@storybook/svelte';
|
||||
| '@storybook/svelte'
|
||||
| '@storybook/react-native';
|
||||
}
|
||||
|
||||
@ -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?"
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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"],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user