From 9b4579fac2fa8a860f3765fbb0a3e169a05d431f Mon Sep 17 00:00:00 2001 From: Alec Winograd Date: Mon, 27 May 2024 05:54:05 -0500 Subject: [PATCH] Update prepareStories to handle more options & strip play functions (#573) * automatically strip play functions for react-native runtime * support includeStories/excludeStories CSF options * feat: add playfn option --- examples/expo-example/.storybook/main.ts | 3 + .../.storybook/storybook.requires.ts | 8 +- .../ActionExample/Actions.stories.tsx | 3 + packages/react-native/package.json | 3 + .../__snapshots__/generate.test.js.snap | 35 +++-- packages/react-native/scripts/generate.js | 16 ++- packages/react-native/src/Start.test.ts | 135 ++++++++++++++++++ packages/react-native/src/Start.tsx | 35 ++++- packages/react-native/src/StartV6.tsx | 2 +- packages/react-native/src/index.ts | 2 + 10 files changed, 225 insertions(+), 17 deletions(-) create mode 100644 packages/react-native/src/Start.test.ts diff --git a/examples/expo-example/.storybook/main.ts b/examples/expo-example/.storybook/main.ts index 8ef4614d3e..0617fb1977 100644 --- a/examples/expo-example/.storybook/main.ts +++ b/examples/expo-example/.storybook/main.ts @@ -22,6 +22,9 @@ const main: StorybookConfig = { '@storybook/addon-ondevice-backgrounds', '@storybook/addon-ondevice-actions', ], + reactNative: { + playFn: false, + }, }; export default main; diff --git a/examples/expo-example/.storybook/storybook.requires.ts b/examples/expo-example/.storybook/storybook.requires.ts index 1993c25858..995dfe91d5 100644 --- a/examples/expo-example/.storybook/storybook.requires.ts +++ b/examples/expo-example/.storybook/storybook.requires.ts @@ -57,13 +57,19 @@ global.STORIES = normalizedStories; // @ts-ignore module?.hot?.accept?.(); +const options = { playFn: false }; + if (!global.view) { global.view = start({ annotations, storyEntries: normalizedStories, + options, }); } else { - const { importMap } = prepareStories({ storyEntries: normalizedStories }); + const { importMap } = prepareStories({ + storyEntries: normalizedStories, + options, + }); global.view._preview.onStoriesChanged({ importFn: async (importPath: string) => importMap[importPath], diff --git a/examples/expo-example/components/ActionExample/Actions.stories.tsx b/examples/expo-example/components/ActionExample/Actions.stories.tsx index 1588ec7c66..68c3a23241 100644 --- a/examples/expo-example/components/ActionExample/Actions.stories.tsx +++ b/examples/expo-example/components/ActionExample/Actions.stories.tsx @@ -37,4 +37,7 @@ export const AnotherAction: Story = { argTypes: { onPress: { action: 'pressed a different button' }, }, + play: () => { + console.log('hello'); + }, }; diff --git a/packages/react-native/package.json b/packages/react-native/package.json index ee1a4b2584..d6506cfa7c 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -38,6 +38,9 @@ "test:ci": "jest" }, "jest": { + "transformIgnorePatterns": [ + "node_modules/(?!react-native|@react-native)" + ], "modulePathIgnorePatterns": [ "dist/" ], diff --git a/packages/react-native/scripts/__snapshots__/generate.test.js.snap b/packages/react-native/scripts/__snapshots__/generate.test.js.snap index 3d4908d9a1..a0e23f99cf 100644 --- a/packages/react-native/scripts/__snapshots__/generate.test.js.snap +++ b/packages/react-native/scripts/__snapshots__/generate.test.js.snap @@ -34,13 +34,16 @@ import "@storybook/addon-ondevice-actions/register"; // @ts-ignore module?.hot?.accept?.(); + + if (!global.view) { global.view = start({ annotations, - storyEntries: normalizedStories + storyEntries: normalizedStories, + }); } else { - const { importMap } = prepareStories({ storyEntries: normalizedStories }); + const { importMap } = prepareStories({ storyEntries: normalizedStories, }); global.view._preview.onStoriesChanged({ importFn: async (importPath: string) => importMap[importPath], @@ -89,13 +92,16 @@ import "@storybook/addon-ondevice-actions/register"; // @ts-ignore module?.hot?.accept?.(); + + if (!global.view) { global.view = start({ annotations, - storyEntries: normalizedStories + storyEntries: normalizedStories, + }); } else { - const { importMap } = prepareStories({ storyEntries: normalizedStories }); + const { importMap } = prepareStories({ storyEntries: normalizedStories, }); global.view._preview.onStoriesChanged({ importFn: async (importPath: string) => importMap[importPath], @@ -144,13 +150,16 @@ import "@storybook/addon-ondevice-actions/register"; // @ts-ignore module?.hot?.accept?.(); + + if (!global.view) { global.view = start({ annotations, - storyEntries: normalizedStories + storyEntries: normalizedStories, + }); } else { - const { importMap } = prepareStories({ storyEntries: normalizedStories }); + const { importMap } = prepareStories({ storyEntries: normalizedStories, }); global.view._preview.onStoriesChanged({ importFn: async (importPath: string) => importMap[importPath], @@ -199,13 +208,16 @@ import "@storybook/addon-ondevice-actions/register"; // @ts-ignore module?.hot?.accept?.(); + + if (!global.view) { global.view = start({ annotations, - storyEntries: normalizedStories + storyEntries: normalizedStories, + }); } else { - const { importMap } = prepareStories({ storyEntries: normalizedStories }); + const { importMap } = prepareStories({ storyEntries: normalizedStories, }); global.view._preview.onStoriesChanged({ importFn: async (importPath: string) => importMap[importPath], @@ -249,13 +261,16 @@ import "@storybook/addon-ondevice-actions/register"; module?.hot?.accept?.(); + + if (!global.view) { global.view = start({ annotations, - storyEntries: normalizedStories + storyEntries: normalizedStories, + }); } else { - const { importMap } = prepareStories({ storyEntries: normalizedStories }); + const { importMap } = prepareStories({ storyEntries: normalizedStories, }); global.view._preview.onStoriesChanged({ importFn: async (importPath) => importMap[importPath], diff --git a/packages/react-native/scripts/generate.js b/packages/react-native/scripts/generate.js index 67656ba74f..98742a340d 100644 --- a/packages/react-native/scripts/generate.js +++ b/packages/react-native/scripts/generate.js @@ -55,6 +55,15 @@ function generate({ configPath, absolute = false, useJs = false }) { ? "require('@storybook/addon-actions/preview')" : ''; + let options = ''; + let optionsVar = ''; + const reactNativeOptions = main.reactNative; + + if (reactNativeOptions && typeof reactNativeOptions === 'object') { + optionsVar = `const options = ${JSON.stringify(reactNativeOptions)}`; + options = 'options'; + } + const previewExists = getPreviewExists({ configPath }); const annotations = `[${previewExists ? "require('./preview')," : ''}${doctools}, ${enhancer}]`; @@ -84,13 +93,16 @@ function generate({ configPath, absolute = false, useJs = false }) { ${useJs ? '' : '// @ts-ignore'} module?.hot?.accept?.(); + ${optionsVar} + if (!global.view) { global.view = start({ annotations, - storyEntries: normalizedStories + storyEntries: normalizedStories, + ${options} }); } else { - const { importMap } = prepareStories({ storyEntries: normalizedStories }); + const { importMap } = prepareStories({ storyEntries: normalizedStories, ${options} }); global.view._preview.onStoriesChanged({ importFn: async (importPath${useJs ? '' : ': string'}) => importMap[importPath], diff --git a/packages/react-native/src/Start.test.ts b/packages/react-native/src/Start.test.ts new file mode 100644 index 0000000000..c8b7453afd --- /dev/null +++ b/packages/react-native/src/Start.test.ts @@ -0,0 +1,135 @@ +import { prepareStories } from './Start'; + +describe('prepareStories', () => { + test('prepares a standard CSF story file', () => { + const req = () => { + return require('../../../examples/expo-example/components/InputExample/TextInput.stories'); + }; + req.keys = () => ['./TextInput.stories.tsx']; + + const result = prepareStories({ + storyEntries: [ + { + titlePrefix: '', + directory: './src', + files: '**/*.stories.?(ts|tsx|js|jsx)', + importPathMatcher: + /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/, + req, + }, + ], + }); + expect(result).toEqual>({ + importMap: { + './src/TextInput.stories.tsx': { + Basic: { + args: { + placeholder: 'Type something', + }, + }, + default: { + component: expect.any(Function), + parameters: { + notes: 'Use this example to test the software keyboard related issues.', + }, + title: 'TextInput', + }, + }, + }, + index: { + v: 4, + entries: { + 'textinput--basic': { + id: 'textinput--basic', + importPath: './src/TextInput.stories.tsx', + name: 'Basic', + tags: ['story'], + title: 'TextInput', + type: 'story', + }, + }, + }, + }); + }); + + test('ignores stories matching excludeStories pattern', () => { + const req = () => { + const stories = { + ...require('../../../examples/expo-example/components/InputExample/TextInput.stories'), + }; + stories.Ignored = stories.Basic; + stories.default.excludeStories = /Ignored/; + return stories; + }; + req.keys = () => ['./TextInput.stories.tsx']; + + const result = prepareStories({ + storyEntries: [ + { + titlePrefix: '', + directory: './src', + files: '**/*.stories.?(ts|tsx|js|jsx)', + importPathMatcher: + /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/, + req, + }, + ], + }); + expect(result.importMap['./src/TextInput.stories.tsx'].Ignored).toBeUndefined(); + expect(result.index.entries['textinput--basic']).toBeDefined(); + expect(result.index.entries['textinput--ignored']).toBeUndefined(); + }); + + test('ignores stories not matching includeStories pattern', () => { + const req = () => { + const stories = { + ...require('../../../examples/expo-example/components/InputExample/TextInput.stories'), + }; + stories.Ignored = stories.Basic; + stories.default.includeStories = /Basic/; + return stories; + }; + req.keys = () => ['./TextInput.stories.tsx']; + + const result = prepareStories({ + storyEntries: [ + { + titlePrefix: '', + directory: './src', + files: '**/*.stories.?(ts|tsx|js|jsx)', + importPathMatcher: + /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/, + req, + }, + ], + }); + expect(result.importMap['./src/TextInput.stories.tsx'].Ignored).toBeUndefined(); + expect(result.index.entries['textinput--basic']).toBeDefined(); + expect(result.index.entries['textinput--ignored']).toBeUndefined(); + }); + + test('strips play functions from stories', () => { + const req = () => { + const stories = { + ...require('../../../examples/expo-example/components/InputExample/TextInput.stories'), + }; + stories.Basic.play = () => {}; + return stories; + }; + req.keys = () => ['./TextInput.stories.tsx']; + + const result = prepareStories({ + storyEntries: [ + { + titlePrefix: '', + directory: './src', + files: '**/*.stories.?(ts|tsx|js|jsx)', + importPathMatcher: + /^\.(?:(?:^|\/|(?:(?:(?!(?:^|\/)\.).)*?)\/)(?!\.)(?=.)[^/]*?\.stories\.(?:ts|tsx|js|jsx)?)$/, + req, + }, + ], + }); + expect(result.importMap['./src/TextInput.stories.tsx'].Basic.play).toBeUndefined(); + }); +}); diff --git a/packages/react-native/src/Start.tsx b/packages/react-native/src/Start.tsx index e7c6f683bc..dec06511cd 100644 --- a/packages/react-native/src/Start.tsx +++ b/packages/react-native/src/Start.tsx @@ -1,4 +1,4 @@ -import { toId, storyNameFromExport } from '@storybook/csf'; +import { toId, storyNameFromExport, isExportStory } from '@storybook/csf'; import { addons as previewAddons, composeConfigs, @@ -12,10 +12,20 @@ import { View } from './View'; import type { ReactRenderer } from '@storybook/react'; import type { NormalizedStoriesSpecifier, StoryIndex } from '@storybook/types'; +/** Configuration options that are needed at startup, only serialisable values are possible */ +export interface ReactNativeOptions { + /** + * Note that this is for future and play functions are not yet fully supported on native. + */ + playFn?: boolean; +} + export function prepareStories({ storyEntries, + options, }: { storyEntries: Array; + options?: ReactNativeOptions; }) { let index: StoryIndex = { v: 4, @@ -59,6 +69,7 @@ export function prepareStories({ const meta = fileExports.default; Object.keys(fileExports).forEach((key) => { if (key === 'default') return; + if (!isExportStory(key, fileExports.default)) return; const exportValue = fileExports[key]; if (!exportValue) return; @@ -79,7 +90,23 @@ export function prepareStories({ tags: ['story'], }; - importMap[`${root}/${filename.substring(2)}`] = req(filename); + const importedStories = req(filename); + const stories = Object.entries(importedStories).reduce( + (carry, [storyKey, story]: [string, Readonly>]) => { + if (!isExportStory(storyKey, fileExports.default)) return carry; + if (story.play && !options?.playFn) { + // play functions are not yet fully supported on native. + // There is a new option in main.js to turn them on for future use. + carry[storyKey] = { ...story, play: undefined }; + } else { + carry[storyKey] = story; + } + return carry; + }, + {} + ); + + importMap[`${root}/${filename.substring(2)}`] = stories; } else { console.log(`Unexpected error while loading ${filename}: could not find title`); } @@ -119,11 +146,13 @@ export const getProjectAnnotations = (view: View, annotations: any[]) => async ( export function start({ annotations, storyEntries, + options, }: { storyEntries: Array; annotations: any[]; + options?: ReactNativeOptions; }) { - const { index, importMap } = prepareStories({ storyEntries }); + const { index, importMap } = prepareStories({ storyEntries, options }); const channel = createBrowserChannel({ page: 'preview' }); diff --git a/packages/react-native/src/StartV6.tsx b/packages/react-native/src/StartV6.tsx index 7610cd7e62..a4a0338eb0 100644 --- a/packages/react-native/src/StartV6.tsx +++ b/packages/react-native/src/StartV6.tsx @@ -38,7 +38,7 @@ export function start() { const previewView = { prepareForStory: () => { - return <>; + return (<>) as any; }, prepareForDocs: (): any => {}, showErrorDisplay: () => {}, diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 4eb1a4fec8..cd34c83389 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -1,4 +1,5 @@ import type { StorybookConfig as StorybookConfigBase } from '@storybook/types'; +import type { ReactNativeOptions } from './Start'; export { darkTheme, theme, type Theme } from '@storybook/react-native-theming'; export { start, prepareStories, getProjectAnnotations } from './Start'; @@ -6,4 +7,5 @@ export { start, prepareStories, getProjectAnnotations } from './Start'; export interface StorybookConfig { stories: StorybookConfigBase['stories']; addons: string[]; + reactNative?: ReactNativeOptions; }