diff --git a/docs/generated/api-react-native/executors/storybook.md b/docs/generated/api-react-native/executors/storybook.md new file mode 100644 index 0000000000..c59d54defd --- /dev/null +++ b/docs/generated/api-react-native/executors/storybook.md @@ -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. diff --git a/docs/generated/api-react-native/generators/component-story.md b/docs/generated/api-react-native/generators/component-story.md new file mode 100644 index 0000000000..789898a6dc --- /dev/null +++ b/docs/generated/api-react-native/generators/component-story.md @@ -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. diff --git a/docs/generated/api-react-native/generators/stories.md b/docs/generated/api-react-native/generators/stories.md new file mode 100644 index 0000000000..2b040cb980 --- /dev/null +++ b/docs/generated/api-react-native/generators/stories.md @@ -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 diff --git a/docs/generated/api-react-native/generators/storybook-configuration.md b/docs/generated/api-react-native/generators/storybook-configuration.md new file mode 100644 index 0000000000..09bc218e00 --- /dev/null +++ b/docs/generated/api-react-native/generators/storybook-configuration.md @@ -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 /project.json rather than including it inside workspace.json diff --git a/docs/generated/api-react/generators/stories.md b/docs/generated/api-react/generators/stories.md index 65ff592c37..450e56af4f 100644 --- a/docs/generated/api-react/generators/stories.md +++ b/docs/generated/api-react/generators/stories.md @@ -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 diff --git a/docs/generated/api-react/generators/storybook-configuration.md b/docs/generated/api-react/generators/storybook-configuration.md index dfe1a2e6df..3bd1828248 100644 --- a/docs/generated/api-react/generators/storybook-configuration.md +++ b/docs/generated/api-react/generators/storybook-configuration.md @@ -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 diff --git a/docs/map.json b/docs/map.json index 2a369c5a17..6e3cee1386 100644 --- a/docs/map.json +++ b/docs/map.json @@ -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", diff --git a/e2e/react-native/src/react-native.test.ts b/e2e/react-native/src/react-native.test.ts index a0666a33e7..78c5e22273 100644 --- a/e2e/react-native/src/react-native.test.ts +++ b/e2e/react-native/src/react-native.test.ts @@ -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 () => { diff --git a/package.json b/package.json index 01f80156ac..5368ab2b4c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/react-native/executors.json b/packages/react-native/executors.json index 07c0a1c01e..24cbbbd094 100644 --- a/packages/react-native/executors.json +++ b/packages/react-native/executors.json @@ -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" } } } diff --git a/packages/react-native/generators.json b/packages/react-native/generators.json index b8165f7798..13aaaa6647 100644 --- a/packages/react-native/generators.json +++ b/packages/react-native/generators.json @@ -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 } } } diff --git a/packages/react-native/package.json b/packages/react-native/package.json index 790cf70f10..a34d5aafdb 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -28,6 +28,7 @@ "@nrwl/devkit": "*", "@nrwl/jest": "*", "@nrwl/linter": "*", + "@nrwl/storybook": "*", "@nrwl/react": "*", "@nrwl/workspace": "*", "chalk": "^4.1.0", diff --git a/packages/react-native/src/executors/storybook/compat.ts b/packages/react-native/src/executors/storybook/compat.ts new file mode 100644 index 0000000000..c279293d0c --- /dev/null +++ b/packages/react-native/src/executors/storybook/compat.ts @@ -0,0 +1,5 @@ +import { convertNxExecutor } from '@nrwl/devkit'; + +import storybookExecutor from './storybook.impl'; + +export default convertNxExecutor(storybookExecutor); diff --git a/packages/react-native/src/executors/storybook/schema.d.ts b/packages/react-native/src/executors/storybook/schema.d.ts new file mode 100644 index 0000000000..105057ee6d --- /dev/null +++ b/packages/react-native/src/executors/storybook/schema.d.ts @@ -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; +} diff --git a/packages/react-native/src/executors/storybook/schema.json b/packages/react-native/src/executors/storybook/schema.json new file mode 100644 index 0000000000..447741a200 --- /dev/null +++ b/packages/react-native/src/executors/storybook/schema.json @@ -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"] +} diff --git a/packages/react-native/src/executors/storybook/storybook.impl.ts b/packages/react-native/src/executors/storybook/storybook.impl.ts new file mode 100644 index 0000000000..206f609416 --- /dev/null +++ b/packages/react-native/src/executors/storybook/storybook.impl.ts @@ -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 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; + }, []); +} diff --git a/packages/react-native/src/generators/application/files/app/metro.config.js.template b/packages/react-native/src/generators/application/files/app/metro.config.js.template index 8f41e3afa5..8152cd68bc 100644 --- a/packages/react-native/src/generators/application/files/app/metro.config.js.template +++ b/packages/react-native/src/generators/application/files/app/metro.config.js.template @@ -19,6 +19,7 @@ module.exports = (async () => { resolver: { assetExts: assetExts.filter((ext) => ext !== 'svg'), sourceExts: [...sourceExts, 'svg'], + resolverMainFields: ['sbmodern', 'browser', 'main'], }, }, { diff --git a/packages/react-native/src/generators/application/files/app/package.json.template b/packages/react-native/src/generators/application/files/app/package.json.template index 15144b1673..64a5051b0a 100644 --- a/packages/react-native/src/generators/application/files/app/package.json.template +++ b/packages/react-native/src/generators/application/files/app/package.json.template @@ -8,6 +8,7 @@ "react": "*", "react-native": "*", "react-native-config": "*", - "react-native-svg": "*" + "react-native-svg": "*", + "@react-native-async-storage/async-storage": "*" } } diff --git a/packages/react-native/src/generators/application/files/app/src/app/App.tsx.template b/packages/react-native/src/generators/application/files/app/src/app/App.tsx.template index d039996946..7376311a00 100644 --- a/packages/react-native/src/generators/application/files/app/src/app/App.tsx.template +++ b/packages/react-native/src/generators/application/files/app/src/app/App.tsx.template @@ -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(0); const scrollViewRef = useRef(null); diff --git a/packages/react-native/src/generators/component-story/component-story.spec.ts b/packages/react-native/src/generators/component-story/component-story.spec.ts new file mode 100644 index 0000000000..d84486df04 --- /dev/null +++ b/packages/react-native/src/generators/component-story/component-story.spec.ts @@ -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', () => ); + `); + }); + }); + + 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 ( +
+

Welcome to test component

+
+ ); + }; + + 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', () => ); + `); + }); + }); + + 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 ( + + Welcome to test! + + ); + } + + 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', () => ); + `); + }); + }); + + 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', () => ); + `); + }); + }); + + 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 ( +
+

Welcome to test component, {props.name}

+ +
+ ); + }; + + 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', () => ); + `); + }); + }); + + [ + { + name: 'default export function', + src: `export default function Test(props: TestProps) { + return ( +
+

Welcome to test component, {props.name}

+
+ ); + }; + `, + }, + { + name: 'function and then export', + src: ` + function Test(props: TestProps) { + return ( +
+

Welcome to test component, {props.name}

+
+ ); + }; + export default Test; + `, + }, + { + name: 'arrow function', + src: ` + const Test = (props: TestProps) => { + return ( +
+

Welcome to test component, {props.name}

+
+ ); + }; + export default Test; + `, + }, + { + name: 'arrow function without {..}', + src: ` + const Test = (props: TestProps) =>

Welcome to test component, {props.name}

; + export default Test + `, + }, + { + name: 'direct export of component class', + src: ` + export default class Test extends React.Component { + render() { + return

Welcome to test component, {this.props.name}

; + } + } + `, + }, + { + name: 'component class & then default export', + src: ` + class Test extends React.Component { + render() { + return

Welcome to test component, {this.props.name}

; + } + } + export default Test + `, + }, + { + name: 'PureComponent class & then default export', + src: ` + class Test extends React.PureComponent { + render() { + return

Welcome to test component, {this.props.name}

; + } + } + export default Test + `, + }, + { + name: 'direct export of component class new JSX transform', + src: ` + export default class Test extends Component { + render() { + return

Welcome to test component, {this.props.name}

; + } + } + `, + }, + { + name: 'component class & then default export new JSX transform', + src: ` + class Test extends Component { + render() { + return

Welcome to test component, {this.props.name}

; + } + } + export default Test + `, + }, + { + name: 'PureComponent class & then default export new JSX transform', + src: ` + class Test extends PureComponent { + render() { + return

Welcome to test component, {this.props.name}

; + } + } + 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', () => ); + `); + }); + }); + }); + }); + + 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', () => ); + `); + }); + }); +}); + +export async function createTestUILib( + libName: string, + useEsLint = false +): Promise { + 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; +} diff --git a/packages/react-native/src/generators/component-story/component-story.ts b/packages/react-native/src/generators/component-story/component-story.ts new file mode 100644 index 0000000000..72529f7422 --- /dev/null +++ b/packages/react-native/src/generators/component-story/component-story.ts @@ -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 = { + [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 +); diff --git a/packages/react-native/src/generators/component-story/files/__componentFileName__.stories.__fileExt__ b/packages/react-native/src/generators/component-story/files/__componentFileName__.stories.__fileExt__ new file mode 100644 index 0000000000..c74b2060aa --- /dev/null +++ b/packages/react-native/src/generators/component-story/files/__componentFileName__.stories.__fileExt__ @@ -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} <% } %>/> + )); \ No newline at end of file diff --git a/packages/react-native/src/generators/component-story/schema.d.ts b/packages/react-native/src/generators/component-story/schema.d.ts new file mode 100644 index 0000000000..5fa087a5ab --- /dev/null +++ b/packages/react-native/src/generators/component-story/schema.d.ts @@ -0,0 +1,4 @@ +export interface CreateComponentStoriesFileSchema { + project: string; + componentPath: string; +} diff --git a/packages/react-native/src/generators/component-story/schema.json b/packages/react-native/src/generators/component-story/schema.json new file mode 100644 index 0000000000..6ef59d5d40 --- /dev/null +++ b/packages/react-native/src/generators/component-story/schema.json @@ -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"] +} diff --git a/packages/react-native/src/generators/init/init.ts b/packages/react-native/src/generators/init/init.ts index e7d20d9f9d..80074e44b2 100644 --- a/packages/react-native/src/generators/init/init.ts +++ b/packages/react-native/src/generators/init/init.ts @@ -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 diff --git a/packages/react-native/src/generators/stories/schema.d.ts b/packages/react-native/src/generators/stories/schema.d.ts new file mode 100644 index 0000000000..24167f3f29 --- /dev/null +++ b/packages/react-native/src/generators/stories/schema.d.ts @@ -0,0 +1,3 @@ +export interface StorybookStoriesSchema { + project: string; +} diff --git a/packages/react-native/src/generators/stories/schema.json b/packages/react-native/src/generators/stories/schema.json new file mode 100644 index 0000000000..5443fcef59 --- /dev/null +++ b/packages/react-native/src/generators/stories/schema.json @@ -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"] +} diff --git a/packages/react-native/src/generators/stories/stories-app.spec.ts b/packages/react-native/src/generators/stories/stories-app.spec.ts new file mode 100644 index 0000000000..45b56b28b1 --- /dev/null +++ b/packages/react-native/src/generators/stories/stories-app.spec.ts @@ -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 { + let appTree = createTreeWithEmptyWorkspace(); + appTree.write('.gitignore', ''); + + await applicationGenerator(appTree, { + linter: Linter.EsLint, + skipFormat: false, + style: 'css', + unitTestRunner: 'none', + name: libName, + }); + return appTree; +} diff --git a/packages/react-native/src/generators/stories/stories-lib.spec.ts b/packages/react-native/src/generators/stories/stories-lib.spec.ts new file mode 100644 index 0000000000..2b531a6728 --- /dev/null +++ b/packages/react-native/src/generators/stories/stories-lib.spec.ts @@ -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 { + 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; +} diff --git a/packages/react-native/src/generators/stories/stories.ts b/packages/react-native/src/generators/stories/stories.ts new file mode 100644 index 0000000000..c514e13ba1 --- /dev/null +++ b/packages/react-native/src/generators/stories/stories.ts @@ -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); diff --git a/packages/react-native/src/generators/storybook-configuration/configuration.spec.ts b/packages/react-native/src/generators/storybook-configuration/configuration.spec.ts new file mode 100644 index 0000000000..a34566b133 --- /dev/null +++ b/packages/react-native/src/generators/storybook-configuration/configuration.spec.ts @@ -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 { + 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 { + 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; +} diff --git a/packages/react-native/src/generators/storybook-configuration/configuration.ts b/packages/react-native/src/generators/storybook-configuration/configuration.ts new file mode 100644 index 0000000000..2f0155c52c --- /dev/null +++ b/packages/react-native/src/generators/storybook-configuration/configuration.ts @@ -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 { + 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 +); diff --git a/packages/react-native/src/generators/storybook-configuration/files/app/storybook.ts.template b/packages/react-native/src/generators/storybook-configuration/files/app/storybook.ts.template new file mode 100644 index 0000000000..feb62b0ed1 --- /dev/null +++ b/packages/react-native/src/generators/storybook-configuration/files/app/storybook.ts.template @@ -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; diff --git a/packages/react-native/src/generators/storybook-configuration/files/app/toggle-storybook.tsx.template b/packages/react-native/src/generators/storybook-configuration/files/app/toggle-storybook.tsx.template new file mode 100644 index 0000000000..e9220676de --- /dev/null +++ b/packages/react-native/src/generators/storybook-configuration/files/app/toggle-storybook.tsx.template @@ -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 { + 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 { + 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 ? : null; + } else { + return props.children; + } +} + +export default () => { + return ( + + + + ); +}; diff --git a/packages/react-native/src/generators/storybook-configuration/files/root/story-loader.js.template b/packages/react-native/src/generators/storybook-configuration/files/root/story-loader.js.template new file mode 100644 index 0000000000..162b6755d2 --- /dev/null +++ b/packages/react-native/src/generators/storybook-configuration/files/root/story-loader.js.template @@ -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, +}; diff --git a/packages/react-native/src/generators/storybook-configuration/lib/create-storybook-files.ts b/packages/react-native/src/generators/storybook-configuration/lib/create-storybook-files.ts new file mode 100644 index 0000000000..d5e4d6835c --- /dev/null +++ b/packages/react-native/src/generators/storybook-configuration/lib/create-storybook-files.ts @@ -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 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); + } +} diff --git a/packages/react-native/src/generators/storybook-configuration/lib/replace-app-import-with-storybook-toggle.spec.ts b/packages/react-native/src/generators/storybook-configuration/lib/replace-app-import-with-storybook-toggle.spec.ts new file mode 100644 index 0000000000..5a21acd81f --- /dev/null +++ b/packages/react-native/src/generators/storybook-configuration/lib/replace-app-import-with-storybook-toggle.spec.ts @@ -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);` + ); + }); +}); diff --git a/packages/react-native/src/generators/storybook-configuration/lib/replace-app-import-with-storybook-toggle.ts b/packages/react-native/src/generators/storybook-configuration/lib/replace-app-import-with-storybook-toggle.ts new file mode 100644 index 0000000000..ac37abbbc9 --- /dev/null +++ b/packages/react-native/src/generators/storybook-configuration/lib/replace-app-import-with-storybook-toggle.ts @@ -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}.` + ); + } +} diff --git a/packages/react-native/src/generators/storybook-configuration/schema.d.ts b/packages/react-native/src/generators/storybook-configuration/schema.d.ts new file mode 100644 index 0000000000..eee8589dab --- /dev/null +++ b/packages/react-native/src/generators/storybook-configuration/schema.d.ts @@ -0,0 +1,9 @@ +import { Linter } from '@nrwl/linter'; + +export interface StorybookConfigureSchema { + name: string; + generateStories?: boolean; + js?: boolean; + linter?: Linter; + standaloneConfig?: boolean; +} diff --git a/packages/react-native/src/generators/storybook-configuration/schema.json b/packages/react-native/src/generators/storybook-configuration/schema.json new file mode 100644 index 0000000000..4c3fc84859 --- /dev/null +++ b/packages/react-native/src/generators/storybook-configuration/schema.json @@ -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 /project.json rather than including it inside workspace.json", + "type": "boolean" + } + }, + "required": ["name"] +} diff --git a/packages/react-native/src/utils/format-file.ts b/packages/react-native/src/utils/format-file.ts new file mode 100644 index 0000000000..84364f0ff7 --- /dev/null +++ b/packages/react-native/src/utils/format-file.ts @@ -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', + } + ); +} diff --git a/packages/react-native/src/utils/versions.ts b/packages/react-native/src/utils/versions.ts index 6dfc597cb3..d05206b8c5 100644 --- a/packages/react-native/src/utils/versions.ts +++ b/packages/react-native/src/utils/versions.ts @@ -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'; diff --git a/packages/react/generators.json b/packages/react/generators.json index b8bc6c9030..2d766fd3b8 100644 --- a/packages/react/generators.json +++ b/packages/react/generators.json @@ -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 }, diff --git a/packages/react/src/generators/component-story/component-story.ts b/packages/react/src/generators/component-story/component-story.ts index 6a025ff4c9..7173b87ec2 100644 --- a/packages/react/src/generators/component-story/component-story.ts +++ b/packages/react/src/generators/component-story/component-story.ts @@ -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, } ); } diff --git a/packages/storybook/src/executors/models.ts b/packages/storybook/src/executors/models.ts index 3f404ab741..b3b7c8de69 100644 --- a/packages/storybook/src/executors/models.ts +++ b/packages/storybook/src/executors/models.ts @@ -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; } diff --git a/packages/storybook/src/executors/storybook/storybook.impl.ts b/packages/storybook/src/executors/storybook/storybook.impl.ts index e195d77ab4..c9bed0f39e 100644 --- a/packages/storybook/src/executors/storybook/storybook.impl.ts +++ b/packages/storybook/src/executors/storybook/storybook.impl.ts @@ -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; diff --git a/packages/storybook/src/generators/configuration/configuration.ts b/packages/storybook/src/generators/configuration/configuration.ts index a9370e36bd..8294900540 100644 --- a/packages/storybook/src/generators/configuration/configuration.ts +++ b/packages/storybook/src/generators/configuration/configuration.ts @@ -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(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(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', diff --git a/packages/storybook/src/generators/configuration/project-files/.storybook/main.js__tmpl__ b/packages/storybook/src/generators/configuration/project-files/.storybook/main.js__tmpl__ index 37c8f886b2..fdffbba17e 100644 --- a/packages/storybook/src/generators/configuration/project-files/.storybook/main.js__tmpl__ +++ b/packages/storybook/src/generators/configuration/project-files/.storybook/main.js__tmpl__ @@ -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) { diff --git a/packages/storybook/src/generators/configuration/project-files/.storybook/tsconfig.json__tmpl__ b/packages/storybook/src/generators/configuration/project-files/.storybook/tsconfig.json__tmpl__ index 0bb91366c6..ea42285576 100644 --- a/packages/storybook/src/generators/configuration/project-files/.storybook/tsconfig.json__tmpl__ +++ b/packages/storybook/src/generators/configuration/project-files/.storybook/tsconfig.json__tmpl__ @@ -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"<% } %>] } diff --git a/packages/storybook/src/generators/configuration/schema.d.ts b/packages/storybook/src/generators/configuration/schema.d.ts index 1c54b809e1..c5696f46cb 100644 --- a/packages/storybook/src/generators/configuration/schema.d.ts +++ b/packages/storybook/src/generators/configuration/schema.d.ts @@ -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; diff --git a/packages/storybook/src/generators/init/init.ts b/packages/storybook/src/generators/init/init.ts index 9668b6bfdc..7ac531834b 100644 --- a/packages/storybook/src/generators/init/init.ts +++ b/packages/storybook/src/generators/init/init.ts @@ -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); } diff --git a/packages/storybook/src/generators/init/schema.d.ts b/packages/storybook/src/generators/init/schema.d.ts index 8469eaf6c3..9514448933 100644 --- a/packages/storybook/src/generators/init/schema.d.ts +++ b/packages/storybook/src/generators/init/schema.d.ts @@ -6,5 +6,6 @@ export interface Schema { | '@storybook/web-components' | '@storybook/vue' | '@storybook/vue3' - | '@storybook/svelte'; + | '@storybook/svelte' + | '@storybook/react-native'; } diff --git a/packages/storybook/src/generators/init/schema.json b/packages/storybook/src/generators/init/schema.json index 45fe5e80f4..3f51651b94 100644 --- a/packages/storybook/src/generators/init/schema.json +++ b/packages/storybook/src/generators/init/schema.json @@ -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?" } diff --git a/packages/storybook/src/generators/migrate-defaults-5-to-6/migrate-defaults-5-to-6.ts b/packages/storybook/src/generators/migrate-defaults-5-to-6/migrate-defaults-5-to-6.ts index 10fb1703ff..be650a146d 100644 --- a/packages/storybook/src/generators/migrate-defaults-5-to-6/migrate-defaults-5-to-6.ts +++ b/packages/storybook/src/generators/migrate-defaults-5-to-6/migrate-defaults-5-to-6.ts @@ -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, diff --git a/packages/storybook/src/utils/utilities.ts b/packages/storybook/src/utils/utilities.ts index 48bf4c7120..aac3ffba7c 100644 --- a/packages/storybook/src/utils/utilities.ts +++ b/packages/storybook/src/utils/utilities.ts @@ -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; diff --git a/packages/storybook/src/utils/versions.ts b/packages/storybook/src/utils/versions.ts index bc36ab3eeb..06cb95480f 100644 --- a/packages/storybook/src/utils/versions.ts +++ b/packages/storybook/src/utils/versions.ts @@ -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'; diff --git a/tsconfig.base.json b/tsconfig.base.json index 2c82e8ad25..404b585ad8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"],