diff --git a/examples/external-docs/.babelrc b/examples/external-docs/.babelrc new file mode 100644 index 00000000000..202d425a099 --- /dev/null +++ b/examples/external-docs/.babelrc @@ -0,0 +1,7 @@ +{ + "presets": [ + "@babel/preset-env", + "@babel/preset-react", + "@babel/preset-typescript" + ] +} diff --git a/examples/external-docs/README.md b/examples/external-docs/README.md new file mode 100644 index 00000000000..5fe025e663f --- /dev/null +++ b/examples/external-docs/README.md @@ -0,0 +1,3 @@ +# Storybook External Docs Example + +This example demostrates using Stories in an app built outside of SB's build process. diff --git a/examples/external-docs/package.json b/examples/external-docs/package.json new file mode 100644 index 00000000000..ad131088f16 --- /dev/null +++ b/examples/external-docs/package.json @@ -0,0 +1,39 @@ +{ + "name": "@storybook/external-docs", + "version": "6.5.0-alpha.55", + "private": true, + "scripts": { + "build-storybook": "cross-env STORYBOOK_DISPLAY_WARNING=true DISPLAY_WARNING=true build-storybook -c ./src/.storybook", + "debug": "cross-env NODE_OPTIONS=--inspect-brk STORYBOOK_DISPLAY_WARNING=true DISPLAY_WARNING=true start-storybook -p 9011", + "sb": "node ../../lib/cli/bin/index.js", + "start": "SKIP_PREFLIGHT_CHECK=true react-scripts start", + "storybook": "cross-env STORYBOOK_DISPLAY_WARNING=true DISPLAY_WARNING=true start-storybook -p 9011 --no-manager-cache -c ./src/.storybook" + }, + "dependencies": { + "@storybook/addon-essentials": "6.5.0-alpha.58", + "@storybook/components": "6.5.0-alpha.58", + "@storybook/csf": "0.0.2--canary.87bc651.0", + "@storybook/preview-web": "6.5.0-alpha.58", + "@storybook/react": "6.5.0-alpha.58", + "@storybook/store": "6.5.0-alpha.58", + "@storybook/theming": "6.5.0-alpha.58", + "formik": "^2.2.9", + "prop-types": "15.7.2", + "react": "16.14.0", + "react-dom": "16.14.0", + "react-scripts": "^4.0.2" + }, + "devDependencies": { + "@babel/preset-env": "^7.12.11", + "@babel/preset-react": "^7.12.10", + "@babel/preset-typescript": "^7.12.7", + "@testing-library/dom": "^7.31.2", + "@testing-library/user-event": "^13.1.9", + "@types/babel__preset-env": "^7", + "@types/react": "^16.14.23", + "@types/react-dom": "^16.9.14", + "cross-env": "^7.0.3", + "typescript": "^3.9.7", + "webpack": "4" + } +} diff --git a/examples/external-docs/public/favicon.ico b/examples/external-docs/public/favicon.ico new file mode 100644 index 00000000000..c2c86b859ea Binary files /dev/null and b/examples/external-docs/public/favicon.ico differ diff --git a/examples/external-docs/public/index.html b/examples/external-docs/public/index.html new file mode 100644 index 00000000000..c240d2ca8b0 --- /dev/null +++ b/examples/external-docs/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + React App + + + +
+ + + diff --git a/examples/external-docs/public/logo192.png b/examples/external-docs/public/logo192.png new file mode 100644 index 00000000000..fbdb05d4eb6 Binary files /dev/null and b/examples/external-docs/public/logo192.png differ diff --git a/examples/external-docs/public/logo512.png b/examples/external-docs/public/logo512.png new file mode 100644 index 00000000000..917458c29a8 Binary files /dev/null and b/examples/external-docs/public/logo512.png differ diff --git a/examples/external-docs/public/manifest.json b/examples/external-docs/public/manifest.json new file mode 100644 index 00000000000..080d6c77ac2 --- /dev/null +++ b/examples/external-docs/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/examples/external-docs/public/robots.txt b/examples/external-docs/public/robots.txt new file mode 100644 index 00000000000..01b0f9a1073 --- /dev/null +++ b/examples/external-docs/public/robots.txt @@ -0,0 +1,2 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * diff --git a/examples/external-docs/src/.storybook/main.ts b/examples/external-docs/src/.storybook/main.ts new file mode 100644 index 00000000000..0465288c69c --- /dev/null +++ b/examples/external-docs/src/.storybook/main.ts @@ -0,0 +1,34 @@ +import type { StorybookConfig } from '@storybook/react/types'; + +const config: StorybookConfig = { + stories: [ + { + directory: '../components', + titlePrefix: 'Demo', + }, + ], + logLevel: 'debug', + addons: ['@storybook/addon-essentials'], + typescript: { + check: true, + checkOptions: {}, + reactDocgenTypescriptOptions: { + propFilter: (prop) => ['label', 'disabled'].includes(prop.name), + }, + }, + core: { + builder: { name: 'webpack4' }, + channelOptions: { allowFunction: false, maxDepth: 10 }, + }, + features: { + postcss: false, + // modernInlineRender: true, + storyStoreV7: !global.navigator?.userAgent?.match?.('jsdom'), + buildStoriesJson: true, + babelModeV7: true, + warnOnLegacyHierarchySeparator: false, + previewMdx2: true, + }, + framework: '@storybook/react', +}; +module.exports = config; diff --git a/examples/external-docs/src/.storybook/preview.js b/examples/external-docs/src/.storybook/preview.js new file mode 100644 index 00000000000..47aa5c189bf --- /dev/null +++ b/examples/external-docs/src/.storybook/preview.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { ThemeProvider, convert, themes } from '@storybook/theming'; + +export const parameters = { + options: { + // storySortV6: (a, b) => ( + // a[1].kind === b[1].kind + // ? 0 + // : a[1].id.localeCompare(b[1].id, undefined, { numeric: true }); + // ), + // storySortV7: (a, b) => ( + // a.title === b.title + // ? 0 + // : a.id.localeCompare(b.id, undefined, { numeric: true }); + // ), + storySort: { + order: ['Examples', 'Docs', 'Demo'], + }, + }, +}; + +export const decorators = [ + (StoryFn) => ( + + + + ), +]; diff --git a/examples/external-docs/src/SecondStoriesPage.tsx b/examples/external-docs/src/SecondStoriesPage.tsx new file mode 100644 index 00000000000..e3b3bc3c201 --- /dev/null +++ b/examples/external-docs/src/SecondStoriesPage.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { DocsProvider, Meta, Story } from './blocks'; + +import meta, { Standard } from './components/AccountForm.stories'; + +export default () => ( + +
+ + + +
+
+); diff --git a/examples/external-docs/src/StoriesPage.tsx b/examples/external-docs/src/StoriesPage.tsx new file mode 100644 index 00000000000..75cd6b9126c --- /dev/null +++ b/examples/external-docs/src/StoriesPage.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { DocsProvider, Meta, Story } from './blocks'; + +import meta, { WithArgs, Basic } from './components/button.stories'; +import EmojiMeta, { WithArgs as EmojiWithArgs } from './components/emoji-button.stories'; + +export default () => ( + +
+ + + + + + +
+
+); diff --git a/examples/external-docs/src/blocks.tsx b/examples/external-docs/src/blocks.tsx new file mode 100644 index 00000000000..9a4281a209d --- /dev/null +++ b/examples/external-docs/src/blocks.tsx @@ -0,0 +1,171 @@ +import React, { createContext, useContext, useRef, useEffect } from 'react'; + +import { Preview } from '@storybook/preview-web'; +import { Path, ModuleExports, StoryIndex } from '@storybook/store'; +import { toId, AnyFramework, ComponentTitle, StoryId } from '@storybook/csf'; + +// @ts-ignore +import * as reactAnnotations from '@storybook/react/dist/esm/client/preview/config'; +// @ts-ignore +import * as previewAnnotations from './.storybook/preview'; + +type StoryExport = any; +type MetaExport = any; +type ExportName = string; + +const projectAnnotations = { + ...reactAnnotations, + ...previewAnnotations, +}; + +const DocsContext = createContext<{ + setMeta: (meta: MetaExport) => void; + addStory: (story: StoryExport, storyMeta?: MetaExport) => void; + renderStory: (story: StoryExport, element: HTMLElement) => void; +}>({ + setMeta: () => {}, + addStory: () => {}, + renderStory: () => {}, +}); + +export const DocsProvider: React.FC = ({ children }) => { + let pageMeta: MetaExport; + const setMeta = (m: MetaExport) => { + pageMeta = m; + }; + + let nextImportPath = 0; + const importPaths = new Map(); + const getImportPath = (meta: MetaExport) => { + if (!importPaths.has(meta)) { + importPaths.set(meta, `importPath-${nextImportPath}`); + nextImportPath += 1; + } + return importPaths.get(meta) as Path; + }; + + let nextTitle = 0; + const titles = new Map(); + const getTitle = (meta: MetaExport) => { + if (!titles.has(meta)) { + titles.set(meta, `title-${nextTitle}`); + nextTitle += 1; + } + return titles.get(meta); + }; + + let nextExportName = 0; + const exportNames = new Map(); + const getExportName = (story: StoryExport) => { + if (!exportNames.has(story)) { + exportNames.set(story, `export-${nextExportName}`); + nextExportName += 1; + } + return exportNames.get(story) as ExportName; + }; + + const storyIds = new Map(); + + const storyIndex: StoryIndex = { v: 3, stories: {} }; + const knownCsfFiles: Record = {}; + + const addStory = (storyExport: StoryExport, storyMeta?: MetaExport) => { + const meta = storyMeta || pageMeta; + const importPath: Path = getImportPath(meta); + const title: ComponentTitle = meta.title || getTitle(meta); + + const exportName = getExportName(storyExport); + const storyId = toId(title, exportName); + storyIds.set(storyExport, storyId); + + if (!knownCsfFiles[importPath]) { + knownCsfFiles[importPath] = { + default: meta, + }; + } + knownCsfFiles[importPath][exportName] = storyExport; + + storyIndex.stories[storyId] = { + id: storyId, + importPath, + title, + name: 'Name', + }; + }; + + let previewPromise: Promise>; + const getPreview = () => { + const importFn = (importPath: Path) => { + console.log(knownCsfFiles, importPath); + return Promise.resolve(knownCsfFiles[importPath]); + }; + + if (!previewPromise) { + previewPromise = (async () => { + // @ts-ignore + // eslint-disable-next-line no-undef + if (window.preview) { + // @ts-ignore + // eslint-disable-next-line no-undef + (window.preview as PreviewWeb).onStoriesChanged({ + importFn, + storyIndex, + }); + } else { + const preview = new Preview(); + await preview.initialize({ + getStoryIndex: () => storyIndex, + importFn, + getProjectAnnotations: () => projectAnnotations, + }); + // @ts-ignore + // eslint-disable-next-line no-undef + window.preview = preview; + } + + // @ts-ignore + // eslint-disable-next-line no-undef + return window.preview; + })(); + } + + return previewPromise; + }; + + const renderStory = async (storyExport: any, element: HTMLElement) => { + const preview = await getPreview(); + + const storyId = storyIds.get(storyExport); + if (!storyId) throw new Error(`Didn't find story id '${storyId}'`); + const story = await preview.storyStore.loadStory({ storyId }); + + console.log({ story }); + + preview.renderStoryToElement(story, element); + }; + + return ( + + {children} + + ); +}; + +export function Meta({ of }: { of: any }) { + const { setMeta } = useContext(DocsContext); + setMeta(of); + return null; +} + +export function Story({ of, meta }: { of: any; meta?: any }) { + const { addStory, renderStory } = useContext(DocsContext); + + addStory(of, meta); + + const ref = useRef(null); + useEffect(() => { + if (ref.current) renderStory(of, ref.current); + }); + + return
; +} diff --git a/examples/external-docs/src/components/AccountForm.stories.tsx b/examples/external-docs/src/components/AccountForm.stories.tsx new file mode 100644 index 00000000000..46fe06ff649 --- /dev/null +++ b/examples/external-docs/src/components/AccountForm.stories.tsx @@ -0,0 +1,95 @@ +/* eslint-disable storybook/await-interactions */ +/* eslint-disable storybook/use-storybook-testing-library */ +// @TODO: use addon-interactions and remove the rule disable above +import { ComponentStoryObj, ComponentMeta } from '@storybook/react'; +import { screen } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { AccountForm } from './AccountForm'; + +export default { + // Title not needed due to CSF3 auto-title + // title: 'Demo/AccountForm', + component: AccountForm, + parameters: { + layout: 'centered', + }, +} as ComponentMeta; + +// export const Standard = (args: any) => ; +// Standard.args = { passwordVerification: false }; +// Standard.play = () => userEvent.type(screen.getByTestId('email'), 'michael@chromatic.com'); + +export const Standard: ComponentStoryObj = { + // render: (args: AccountFormProps) => , + args: { passwordVerification: false }, +}; + +export const StandardEmailFilled = { + ...Standard, + play: () => userEvent.type(screen.getByTestId('email'), 'michael@chromatic.com'), +}; + +export const StandardEmailFailed = { + ...Standard, + play: async () => { + await userEvent.type(screen.getByTestId('email'), 'michael@chromatic.com.com@com'); + await userEvent.type(screen.getByTestId('password1'), 'testpasswordthatwontfail'); + await userEvent.click(screen.getByTestId('submit')); + }, +}; + +export const StandardPasswordFailed = { + ...Standard, + play: async () => { + await StandardEmailFilled.play(); + await userEvent.type(screen.getByTestId('password1'), 'asdf'); + await userEvent.click(screen.getByTestId('submit')); + }, +}; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export const StandardFailHover = { + ...StandardPasswordFailed, + play: async () => { + await StandardPasswordFailed.play(); + await sleep(100); + await userEvent.hover(screen.getByTestId('password-error-info')); + }, +}; + +export const Verification: ComponentStoryObj = { + args: { passwordVerification: true }, +}; + +export const VerificationPasssword1 = { + ...Verification, + play: async () => { + await StandardEmailFilled.play(); + await userEvent.type(screen.getByTestId('password1'), 'asdfasdf'); + await userEvent.click(screen.getByTestId('submit')); + }, +}; + +export const VerificationPasswordMismatch = { + ...Verification, + play: async () => { + await StandardEmailFilled.play(); + await userEvent.type(screen.getByTestId('password1'), 'asdfasdf'); + await userEvent.type(screen.getByTestId('password2'), 'asdf1234'); + await userEvent.click(screen.getByTestId('submit')); + }, +}; + +export const VerificationSuccess = { + ...Verification, + play: async () => { + await StandardEmailFilled.play(); + await sleep(1000); + await userEvent.type(screen.getByTestId('password1'), 'asdfasdf', { delay: 50 }); + await sleep(1000); + await userEvent.type(screen.getByTestId('password2'), 'asdfasdf', { delay: 50 }); + await sleep(1000); + await userEvent.click(screen.getByTestId('submit')); + }, +}; diff --git a/examples/external-docs/src/components/AccountForm.tsx b/examples/external-docs/src/components/AccountForm.tsx new file mode 100644 index 00000000000..d5e612931ac --- /dev/null +++ b/examples/external-docs/src/components/AccountForm.tsx @@ -0,0 +1,552 @@ +import { keyframes, styled } from '@storybook/theming'; +import { + ErrorMessage, + Field as FormikInput, + Form as FormikForm, + Formik, + FormikProps, +} from 'formik'; +import React, { FC, HTMLAttributes, useCallback, useState } from 'react'; +import { Icons, WithTooltip } from '@storybook/components'; + +const errorMap = { + email: { + required: { + normal: 'Please enter your email address', + tooltip: + 'We do require an email address and a password as a minimum in order to be able to create an account for you to log in with', + }, + format: { + normal: 'Please enter a correctly formatted email address', + tooltip: + 'Your email address is formatted incorrectly and is not correct - please double check for misspelling', + }, + }, + password: { + required: { + normal: 'Please enter a password', + tooltip: 'A password is requried to create an account', + }, + length: { + normal: 'Please enter a password of minimum 6 characters', + tooltip: + 'For security reasons we enforce a password length of minimum 6 characters - but have no other requirements', + }, + }, + verifiedPassword: { + required: { + normal: 'Please verify your password', + tooltip: + 'Verification of your password is required to ensure no errors in the spelling of the password', + }, + match: { + normal: 'Your passwords do not match', + tooltip: + 'Your verification password has to match your password to make sure you have not misspelled', + }, + }, +}; + +// https://emailregex.com/ +const email99RegExp = new RegExp( + // eslint-disable-next-line no-useless-escape + /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ +); + +export interface AccountFormResponse { + success: boolean; +} + +export interface AccountFormValues { + email: string; + password: string; +} + +interface FormValues extends AccountFormValues { + verifiedPassword: string; +} + +interface FormErrors { + email?: string; + emailTooltip?: string; + password?: string; + passwordTooltip?: string; + verifiedPassword?: string; + verifiedPasswordTooltip?: string; +} + +export type AccountFormProps = { + passwordVerification?: boolean; + onSubmit?: (values: AccountFormValues) => void; + onTransactionStart?: (values: AccountFormValues) => void; + onTransactionEnd?: (values: AccountFormResponse) => void; +}; + +export const AccountForm: FC = ({ + passwordVerification, + onSubmit, + onTransactionStart, + onTransactionEnd, +}) => { + const [state, setState] = useState({ + transacting: false, + transactionSuccess: false, + transactionFailure: false, + }); + + const handleFormSubmit = useCallback( + async ({ email, password }: FormValues, { setSubmitting, resetForm }) => { + if (onSubmit) { + onSubmit({ email, password }); + } + + if (onTransactionStart) { + onTransactionStart({ email, password }); + } + + setSubmitting(true); + + setState({ + ...state, + transacting: true, + }); + + await new Promise((r) => setTimeout(r, 2100)); + + const success = Math.random() < 1; + + if (onTransactionEnd) { + onTransactionEnd({ success }); + } + + setSubmitting(false); + resetForm({ values: { email: '', password: '', verifiedPassword: '' } }); + + setState({ + ...state, + transacting: false, + transactionSuccess: success === true, + transactionFailure: success === false, + }); + }, + [setState, onTransactionEnd, onTransactionStart] + ); + + return ( + + + + Storybook icon + + + + + + + + <title>Storybook + + + + + + {!state.transactionSuccess && !state.transactionFailure && ( + Create an account to join the Storybook community + )} + + {state.transactionSuccess && !state.transactionFailure && ( + +

+ Everything is perfect. Your account is ready and we should probably get you started! +

+

So why don't you get started then?

+ { + setState({ + transacting: false, + transactionSuccess: false, + transactionFailure: false, + }); + }} + > + Go back + +
+ )} + {state.transactionFailure && !state.transactionSuccess && ( + +

What a mess, this API is not working

+

+ Someone should probably have a stern talking to about this, but it won't be me - coz + I'm gonna head out into the nice weather +

+ { + setState({ + transacting: false, + transactionSuccess: false, + transactionFailure: false, + }); + }} + > + Go back + +
+ )} + {!state.transactionSuccess && !state.transactionFailure && ( + { + const errors: FormErrors = {}; + + if (!email) { + errors.email = errorMap.email.required.normal; + errors.emailTooltip = errorMap.email.required.tooltip; + } else { + const validEmail = email.match(email99RegExp); + + if (validEmail === null) { + errors.email = errorMap.email.format.normal; + errors.emailTooltip = errorMap.email.format.tooltip; + } + } + + if (!password) { + errors.password = errorMap.password.required.normal; + errors.passwordTooltip = errorMap.password.required.tooltip; + } else if (password.length < 6) { + errors.password = errorMap.password.length.normal; + errors.passwordTooltip = errorMap.password.length.tooltip; + } + + if (passwordVerification && !verifiedPassword) { + errors.verifiedPassword = errorMap.verifiedPassword.required.normal; + errors.verifiedPasswordTooltip = errorMap.verifiedPassword.required.tooltip; + } else if (passwordVerification && password !== verifiedPassword) { + errors.verifiedPassword = errorMap.verifiedPassword.match.normal; + errors.verifiedPasswordTooltip = errorMap.verifiedPassword.match.tooltip; + } + + return errors; + }} + > + {({ errors: _errors, isSubmitting, dirty }: FormikProps) => { + const errors = _errors as FormErrors; + + return ( +
+ + + + {({ field }: { field: HTMLAttributes }) => ( + <> + + {errors.email && ( + {errors.emailTooltip}} + > + + + + + + )} + + )} + + + + + + {({ field }: { field: HTMLAttributes }) => ( + + )} + + {errors.password && ( + {errors.passwordTooltip}}> + + + + + + )} + + {passwordVerification && ( + + + + {({ field }: { field: HTMLAttributes }) => ( + + )} + + {errors.verifiedPassword && ( + {errors.verifiedPasswordTooltip}} + > + + + + + + )} + + )} + + + Create Account + + + Reset + + +
+ ); + }} +
+ )} +
+
+ ); +}; + +const Wrapper = styled.section(({ theme }) => ({ + fontFamily: theme.typography.fonts.base, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + width: 450, + padding: 32, + backgroundColor: theme.background.content, + borderRadius: 7, +})); + +const Brand = styled.div({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}); + +const Title = styled.svg({ + height: 40, + zIndex: 1, + left: -32, + position: 'relative', +}); + +const logoAnimation = keyframes({ + '0': { + transform: 'rotateY(0deg)', + transformOrigin: '50% 5% 0', + }, + '100%': { + transform: 'rotateY(360deg)', + transformOrigin: '50% 5% 0', + }, +}); + +interface LogoProps { + transacting: boolean; +} + +const Logo = styled.svg( + ({ transacting }) => + transacting && { + animation: `${logoAnimation} 1250ms both infinite`, + }, + { height: 40, zIndex: 10, marginLeft: 32 } +); + +const Introduction = styled.p({ + marginTop: 20, + textAlign: 'center', +}); + +const Content = styled.div({ + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'center', + width: 350, + minHeight: 189, + marginTop: 8, +}); + +const Presentation = styled.div({ + textAlign: 'center', +}); + +const Form = styled(FormikForm)({ + width: '100%', + alignSelf: 'flex-start', + '&[aria-disabled="true"]': { + opacity: 0.6, + }, +}); + +const FieldWrapper = styled.div({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'stretch', + marginBottom: 10, +}); + +const Label = styled.label({ + fontSize: 13, + fontWeight: 500, + marginBottom: 6, +}); + +const Input = styled.input(({ theme }) => ({ + fontSize: 14, + color: theme.color.defaultText, + padding: '10px 15px', + borderRadius: 4, + appearance: 'none', + outline: 'none', + border: '0 none', + boxShadow: 'rgb(0 0 0 / 10%) 0px 0px 0px 1px inset', + '&:focus': { + boxShadow: 'rgb(30 167 253) 0px 0px 0px 1px inset', + }, + '&:active': { + boxShadow: 'rgb(30 167 253) 0px 0px 0px 1px inset', + }, + '&[aria-invalid="true"]': { + boxShadow: 'rgb(255 68 0) 0px 0px 0px 1px inset', + }, +})); + +const ErrorWrapper = styled.div({ + display: 'flex', + alignItems: 'flex-start', + fontSize: 11, + marginTop: 6, + cursor: 'help', +}); + +const ErrorIcon = styled(Icons)(({ theme }) => ({ + fill: theme.color.defaultText, + opacity: 0.8, + marginRight: 6, + marginLeft: 2, + marginTop: 1, +})); + +const ErrorTooltip = styled.div(({ theme }) => ({ + fontFamily: theme.typography.fonts.base, + fontSize: 13, + padding: 8, + maxWidth: 350, +})); + +const Actions = styled.div({ + alignSelf: 'stretch', + display: 'flex', + justifyContent: 'space-between', + marginTop: 24, +}); + +const Error = styled(ErrorMessage)({}); + +interface ButtonProps { + dirty?: boolean; +} + +const Button = styled.button({ + backgroundColor: 'transparent', + border: '0 none', + outline: 'none', + appearance: 'none', + fontWeight: 500, + fontSize: 12, + flexBasis: '50%', + cursor: 'pointer', + padding: '11px 16px', + borderRadius: 4, + textTransform: 'uppercase', + '&:focus': { + textDecoration: 'underline', + fontWeight: 700, + }, + '&:active': { + textDecoration: 'underline', + fontWeight: 700, + }, + '&[aria-disabled="true"]': { + cursor: 'default', + }, +}); + +const Submit = styled(Button)(({ theme, dirty }) => ({ + marginRight: 8, + backgroundColor: theme.color.secondary, + color: theme.color.inverseText, + opacity: dirty ? 1 : 0.6, + boxShadow: 'rgb(30 167 253 / 10%) 0 0 0 1px inset', +})); + +const Reset = styled(Button)(({ theme }) => ({ + marginLeft: 8, + boxShadow: 'rgb(30 167 253) 0 0 0 1px inset', + color: theme.color.secondary, +})); diff --git a/examples/external-docs/src/components/button.stories.tsx b/examples/external-docs/src/components/button.stories.tsx new file mode 100644 index 00000000000..7005dc92658 --- /dev/null +++ b/examples/external-docs/src/components/button.stories.tsx @@ -0,0 +1,42 @@ +/* eslint-disable storybook/await-interactions */ +/* eslint-disable storybook/use-storybook-testing-library */ +// @TODO: use addon-interactions and remove the rule disable above +import React from 'react'; +import { Meta, ComponentStory } from '@storybook/react'; +import { screen } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { Button } from './button'; + +export default { + component: Button, + title: 'Examples / Button', + argTypes: { onClick: { action: 'click ' } }, + // render: () => <>hohoho, +} as Meta; + +export const WithArgs: ComponentStory = (args) => +); diff --git a/examples/external-docs/src/components/emoji-button.stories.tsx b/examples/external-docs/src/components/emoji-button.stories.tsx new file mode 100644 index 00000000000..9fdbd03f2c2 --- /dev/null +++ b/examples/external-docs/src/components/emoji-button.stories.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { EmojiButton } from './emoji-button'; + +export default { component: EmojiButton, title: 'Examples / Emoji Button' }; + +export const WithArgs = (args: any) => ; +WithArgs.args = { label: 'With args' }; +export const Basic = () => ; diff --git a/examples/external-docs/src/components/emoji-button.tsx b/examples/external-docs/src/components/emoji-button.tsx new file mode 100644 index 00000000000..f38ee88a0dd --- /dev/null +++ b/examples/external-docs/src/components/emoji-button.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export const EmojiButton = ({ label, ...props }: { label: string }) => ( + +); + +EmojiButton.propTypes = { + /** + * A label to show on the button + */ + label: PropTypes.string, +}; + +EmojiButton.defaultProps = { + label: 'Hello', +}; diff --git a/examples/external-docs/src/index.tsx b/examples/external-docs/src/index.tsx new file mode 100644 index 00000000000..ec04c046a88 --- /dev/null +++ b/examples/external-docs/src/index.tsx @@ -0,0 +1,24 @@ +/* global document */ + +import React, { useState } from 'react'; +import ReactDOM from 'react-dom'; +import StoriesPage from './StoriesPage'; +import SecondStoriesPage from './SecondStoriesPage'; + +const Router = ({ routes }: { routes: (() => JSX.Element)[] }) => { + const [routeNumber, setRoute] = useState(0); + const Route = routes[routeNumber]; + + console.log(routeNumber); + return ( +
+ + {/* eslint-disable-next-line react/button-has-type */} + +
+ ); +}; + +const App = () => ; + +ReactDOM.render(, document.getElementById('root')); diff --git a/examples/external-docs/src/react-app-env.d.ts b/examples/external-docs/src/react-app-env.d.ts new file mode 100644 index 00000000000..6431bc5fc6b --- /dev/null +++ b/examples/external-docs/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/external-docs/tsconfig.json b/examples/external-docs/tsconfig.json new file mode 100644 index 00000000000..dc08a84bc28 --- /dev/null +++ b/examples/external-docs/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "esModuleInterop": true, + "jsx": "react", + "skipLibCheck": true, + "strict": true, + "target": "es5", + "lib": [ + "dom", + "esnext" + ], + "allowJs": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": [ "src/*"] +} diff --git a/lib/cli/src/versions.ts b/lib/cli/src/versions.ts index 931317e5a4b..35b535384de 100644 --- a/lib/cli/src/versions.ts +++ b/lib/cli/src/versions.ts @@ -1,59 +1,59 @@ // auto generated file, do not edit export default { - "@storybook/addon-a11y": "6.5.0-alpha.58", - "@storybook/addon-actions": "6.5.0-alpha.58", - "@storybook/addon-backgrounds": "6.5.0-alpha.58", - "@storybook/addon-controls": "6.5.0-alpha.58", - "@storybook/addon-docs": "6.5.0-alpha.58", - "@storybook/addon-essentials": "6.5.0-alpha.58", - "@storybook/addon-interactions": "6.5.0-alpha.58", - "@storybook/addon-jest": "6.5.0-alpha.58", - "@storybook/addon-links": "6.5.0-alpha.58", - "@storybook/addon-measure": "6.5.0-alpha.58", - "@storybook/addon-outline": "6.5.0-alpha.58", - "@storybook/addon-storyshots": "6.5.0-alpha.58", - "@storybook/addon-storyshots-puppeteer": "6.5.0-alpha.58", - "@storybook/addon-storysource": "6.5.0-alpha.58", - "@storybook/addon-toolbars": "6.5.0-alpha.58", - "@storybook/addon-viewport": "6.5.0-alpha.58", - "@storybook/addons": "6.5.0-alpha.58", - "@storybook/angular": "6.5.0-alpha.58", - "@storybook/api": "6.5.0-alpha.58", - "@storybook/builder-webpack4": "6.5.0-alpha.58", - "@storybook/builder-webpack5": "6.5.0-alpha.58", - "@storybook/channel-postmessage": "6.5.0-alpha.58", - "@storybook/channel-websocket": "6.5.0-alpha.58", - "@storybook/channels": "6.5.0-alpha.58", - "@storybook/cli": "6.5.0-alpha.58", - "@storybook/client-api": "6.5.0-alpha.58", - "@storybook/client-logger": "6.5.0-alpha.58", - "@storybook/codemod": "6.5.0-alpha.58", - "@storybook/components": "6.5.0-alpha.58", - "@storybook/core": "6.5.0-alpha.58", - "@storybook/core-client": "6.5.0-alpha.58", - "@storybook/core-common": "6.5.0-alpha.58", - "@storybook/core-events": "6.5.0-alpha.58", - "@storybook/core-server": "6.5.0-alpha.58", - "@storybook/csf-tools": "6.5.0-alpha.58", - "@storybook/docs-tools": "6.5.0-alpha.58", - "@storybook/ember": "6.5.0-alpha.58", - "@storybook/html": "6.5.0-alpha.58", - "@storybook/instrumenter": "6.5.0-alpha.58", - "@storybook/manager-webpack4": "6.5.0-alpha.58", - "@storybook/manager-webpack5": "6.5.0-alpha.58", - "@storybook/node-logger": "6.5.0-alpha.58", - "@storybook/postinstall": "6.5.0-alpha.58", - "@storybook/preact": "6.5.0-alpha.58", - "@storybook/preview-web": "6.5.0-alpha.58", - "@storybook/react": "6.5.0-alpha.58", - "@storybook/router": "6.5.0-alpha.58", - "@storybook/server": "6.5.0-alpha.58", - "@storybook/source-loader": "6.5.0-alpha.58", - "@storybook/store": "6.5.0-alpha.58", - "@storybook/svelte": "6.5.0-alpha.58", - "@storybook/theming": "6.5.0-alpha.58", - "@storybook/ui": "6.5.0-alpha.58", - "@storybook/vue": "6.5.0-alpha.58", - "@storybook/vue3": "6.5.0-alpha.58", - "@storybook/web-components": "6.5.0-alpha.58" -} \ No newline at end of file + '@storybook/addon-a11y': '6.5.0-alpha.58', + '@storybook/addon-actions': '6.5.0-alpha.58', + '@storybook/addon-backgrounds': '6.5.0-alpha.58', + '@storybook/addon-controls': '6.5.0-alpha.58', + '@storybook/addon-docs': '6.5.0-alpha.58', + '@storybook/addon-essentials': '6.5.0-alpha.58', + '@storybook/addon-interactions': '6.5.0-alpha.58', + '@storybook/addon-jest': '6.5.0-alpha.58', + '@storybook/addon-links': '6.5.0-alpha.58', + '@storybook/addon-measure': '6.5.0-alpha.58', + '@storybook/addon-outline': '6.5.0-alpha.58', + '@storybook/addon-storyshots': '6.5.0-alpha.58', + '@storybook/addon-storyshots-puppeteer': '6.5.0-alpha.58', + '@storybook/addon-storysource': '6.5.0-alpha.58', + '@storybook/addon-toolbars': '6.5.0-alpha.58', + '@storybook/addon-viewport': '6.5.0-alpha.58', + '@storybook/addons': '6.5.0-alpha.58', + '@storybook/angular': '6.5.0-alpha.58', + '@storybook/api': '6.5.0-alpha.58', + '@storybook/builder-webpack4': '6.5.0-alpha.58', + '@storybook/builder-webpack5': '6.5.0-alpha.58', + '@storybook/channel-postmessage': '6.5.0-alpha.58', + '@storybook/channel-websocket': '6.5.0-alpha.58', + '@storybook/channels': '6.5.0-alpha.58', + '@storybook/cli': '6.5.0-alpha.58', + '@storybook/client-api': '6.5.0-alpha.58', + '@storybook/client-logger': '6.5.0-alpha.58', + '@storybook/codemod': '6.5.0-alpha.58', + '@storybook/components': '6.5.0-alpha.58', + '@storybook/core': '6.5.0-alpha.58', + '@storybook/core-client': '6.5.0-alpha.58', + '@storybook/core-common': '6.5.0-alpha.58', + '@storybook/core-events': '6.5.0-alpha.58', + '@storybook/core-server': '6.5.0-alpha.58', + '@storybook/csf-tools': '6.5.0-alpha.58', + '@storybook/docs-tools': '6.5.0-alpha.58', + '@storybook/ember': '6.5.0-alpha.58', + '@storybook/html': '6.5.0-alpha.58', + '@storybook/instrumenter': '6.5.0-alpha.58', + '@storybook/manager-webpack4': '6.5.0-alpha.58', + '@storybook/manager-webpack5': '6.5.0-alpha.58', + '@storybook/node-logger': '6.5.0-alpha.58', + '@storybook/postinstall': '6.5.0-alpha.58', + '@storybook/preact': '6.5.0-alpha.58', + '@storybook/preview-web': '6.5.0-alpha.58', + '@storybook/react': '6.5.0-alpha.58', + '@storybook/router': '6.5.0-alpha.58', + '@storybook/server': '6.5.0-alpha.58', + '@storybook/source-loader': '6.5.0-alpha.58', + '@storybook/store': '6.5.0-alpha.58', + '@storybook/svelte': '6.5.0-alpha.58', + '@storybook/theming': '6.5.0-alpha.58', + '@storybook/ui': '6.5.0-alpha.58', + '@storybook/vue': '6.5.0-alpha.58', + '@storybook/vue3': '6.5.0-alpha.58', + '@storybook/web-components': '6.5.0-alpha.58', +}; diff --git a/lib/preview-web/src/DocsRender.ts b/lib/preview-web/src/DocsRender.ts index 57f6176cf95..d386611584e 100644 --- a/lib/preview-web/src/DocsRender.ts +++ b/lib/preview-web/src/DocsRender.ts @@ -4,15 +4,21 @@ import { Story, StoryStore, CSFFile } from '@storybook/store'; import { Channel } from '@storybook/addons'; import { DOCS_RENDERED } from '@storybook/core-events'; -import { DocsContextProps } from './types'; +import { Render, StoryRender } from './StoryRender'; +import type { DocsContextProps } from './types'; -export class DocsRender { +export class DocsRender implements Render { private canvasElement?: HTMLElement; private context?: DocsContextProps; public disableKeyListeners = false; + static fromStoryRender(storyRender: StoryRender) { + const { channel, store, id, story } = storyRender; + return new DocsRender(channel, store, id, story); + } + // eslint-disable-next-line no-useless-constructor constructor( private channel: Channel, diff --git a/lib/preview-web/src/Preview.tsx b/lib/preview-web/src/Preview.tsx new file mode 100644 index 00000000000..fb57fc374a7 --- /dev/null +++ b/lib/preview-web/src/Preview.tsx @@ -0,0 +1,343 @@ +import dedent from 'ts-dedent'; +import global from 'global'; +import { SynchronousPromise } from 'synchronous-promise'; +import Events from '@storybook/core-events'; +import { logger } from '@storybook/client-logger'; +import { addons, Channel } from '@storybook/addons'; +import { AnyFramework, StoryId, ProjectAnnotations, Args, Globals } from '@storybook/csf'; +import { + ModuleImportFn, + Story, + StoryStore, + StoryIndex, + PromiseLike, + WebProjectAnnotations, +} from '@storybook/store'; + +import { StoryRender } from './StoryRender'; +import { DocsRender } from './DocsRender'; + +const { fetch } = global; + +type MaybePromise = Promise | T; + +const STORY_INDEX_PATH = './stories.json'; + +export class Preview { + channel: Channel; + + serverChannel?: Channel; + + storyStore: StoryStore; + + getStoryIndex?: () => StoryIndex; + + importFn?: ModuleImportFn; + + renderToDOM: WebProjectAnnotations['renderToDOM']; + + storyRenders: StoryRender[] = []; + + previewEntryError?: Error; + + constructor() { + this.channel = addons.getChannel(); + if (global.FEATURES?.storyStoreV7 && addons.hasServerChannel()) { + this.serverChannel = addons.getServerChannel(); + } + this.storyStore = new StoryStore(); + } + + // INITIALIZATION + + // NOTE: the reason that the preview and store's initialization code is written in a promise + // style and not `async-await`, and the use of `SynchronousPromise`s is in order to allow + // storyshots to immediately call `raw()` on the store without waiting for a later tick. + // (Even simple things like `Promise.resolve()` and `await` involve the callback happening + // in the next promise "tick"). + // See the comment in `storyshots-core/src/api/index.ts` for more detail. + initialize({ + getStoryIndex, + importFn, + getProjectAnnotations, + }: { + // In the case of the v6 store, we can only get the index from the facade *after* + // getProjectAnnotations has been run, thus this slightly awkward approach + getStoryIndex?: () => StoryIndex; + importFn: ModuleImportFn; + getProjectAnnotations: () => MaybePromise>; + }) { + // We save these two on initialization in case `getProjectAnnotations` errors, + // in which case we may need them later when we recover. + this.getStoryIndex = getStoryIndex; + this.importFn = importFn; + + this.setupListeners(); + + return this.getProjectAnnotationsOrRenderError(getProjectAnnotations).then( + (projectAnnotations) => this.initializeWithProjectAnnotations(projectAnnotations) + ); + } + + setupListeners() { + this.serverChannel?.on(Events.STORY_INDEX_INVALIDATED, this.onStoryIndexChanged.bind(this)); + + this.channel.on(Events.UPDATE_GLOBALS, this.onUpdateGlobals.bind(this)); + this.channel.on(Events.UPDATE_STORY_ARGS, this.onUpdateArgs.bind(this)); + this.channel.on(Events.RESET_STORY_ARGS, this.onResetArgs.bind(this)); + this.channel.on(Events.FORCE_RE_RENDER, this.onForceReRender.bind(this)); + this.channel.on(Events.FORCE_REMOUNT, this.onForceRemount.bind(this)); + } + + getProjectAnnotationsOrRenderError( + getProjectAnnotations: () => MaybePromise> + ): PromiseLike> { + return SynchronousPromise.resolve() + .then(getProjectAnnotations) + .then((projectAnnotations) => { + this.renderToDOM = projectAnnotations.renderToDOM; + if (!this.renderToDOM) { + throw new Error(dedent` + Expected your framework's preset to export a \`renderToDOM\` field. + + Perhaps it needs to be upgraded for Storybook 6.4? + + More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field + `); + } + return projectAnnotations; + }) + .catch((err) => { + // This is an error extracting the projectAnnotations (i.e. evaluating the previewEntries) and + // needs to be show to the user as a simple error + this.renderPreviewEntryError('Error reading preview.js:', err); + throw err; + }); + } + + // If initialization gets as far as project annotations, this function runs. + initializeWithProjectAnnotations(projectAnnotations: WebProjectAnnotations) { + this.storyStore.setProjectAnnotations(projectAnnotations); + + this.setInitialGlobals(); + + let storyIndexPromise: PromiseLike; + if (global.FEATURES?.storyStoreV7) { + storyIndexPromise = this.getStoryIndexFromServer(); + } else { + if (!this.getStoryIndex) { + throw new Error('No `getStoryIndex` passed defined in v6 mode'); + } + storyIndexPromise = SynchronousPromise.resolve().then(this.getStoryIndex); + } + + return storyIndexPromise + .then((storyIndex: StoryIndex) => this.initializeWithStoryIndex(storyIndex)) + .catch((err) => { + this.renderPreviewEntryError('Error loading story index:', err); + throw err; + }); + } + + async setInitialGlobals() { + this.emitGlobals(); + } + + emitGlobals() { + this.channel.emit(Events.SET_GLOBALS, { + globals: this.storyStore.globals.get() || {}, + globalTypes: this.storyStore.projectAnnotations.globalTypes || {}, + }); + } + + async getStoryIndexFromServer() { + const result = await fetch(STORY_INDEX_PATH); + if (result.status === 200) return result.json() as StoryIndex; + + throw new Error(await result.text()); + } + + // If initialization gets as far as the story index, this function runs. + initializeWithStoryIndex(storyIndex: StoryIndex): PromiseLike { + return this.storyStore.initialize({ + storyIndex, + importFn: this.importFn, + cache: !global.FEATURES?.storyStoreV7, + }); + } + + // EVENT HANDLERS + + // This happens when a config file gets reloaded + async onGetProjectAnnotationsChanged({ + getProjectAnnotations, + }: { + getProjectAnnotations: () => MaybePromise>; + }) { + delete this.previewEntryError; + + const projectAnnotations = await this.getProjectAnnotationsOrRenderError(getProjectAnnotations); + if (!this.storyStore.projectAnnotations) { + await this.initializeWithProjectAnnotations(projectAnnotations); + return; + } + + await this.storyStore.setProjectAnnotations(projectAnnotations); + this.emitGlobals(); + } + + async onStoryIndexChanged() { + delete this.previewEntryError; + + if (!this.storyStore.projectAnnotations) { + // We haven't successfully set project annotations yet, + // we need to do that before we can do anything else. + return; + } + + try { + const storyIndex = await this.getStoryIndexFromServer(); + + // This is the first time the story index worked, let's load it into the store + if (!this.storyStore.storyIndex) { + await this.initializeWithStoryIndex(storyIndex); + } + + // Update the store with the new stories. + await this.onStoriesChanged({ storyIndex }); + } catch (err) { + this.renderPreviewEntryError('Error loading story index:', err); + throw err; + } + } + + // This happens when a glob gets HMR-ed + async onStoriesChanged({ + importFn, + storyIndex, + }: { + importFn?: ModuleImportFn; + storyIndex?: StoryIndex; + }) { + await this.storyStore.onStoriesChanged({ importFn, storyIndex }); + } + + async onUpdateGlobals({ globals }: { globals: Globals }) { + this.storyStore.globals.update(globals); + + await Promise.all(this.storyRenders.map((r) => r.rerender())); + + this.channel.emit(Events.GLOBALS_UPDATED, { + globals: this.storyStore.globals.get(), + initialGlobals: this.storyStore.globals.initialGlobals, + }); + } + + async onUpdateArgs({ storyId, updatedArgs }: { storyId: StoryId; updatedArgs: Args }) { + this.storyStore.args.update(storyId, updatedArgs); + + await Promise.all(this.storyRenders.filter((r) => r.id === storyId).map((r) => r.rerender())); + + this.channel.emit(Events.STORY_ARGS_UPDATED, { + storyId, + args: this.storyStore.args.get(storyId), + }); + } + + async onResetArgs({ storyId, argNames }: { storyId: string; argNames?: string[] }) { + // NOTE: we have to be careful here and avoid await-ing when updating a rendered's args. + // That's because below in `renderStoryToElement` we have also bound to this event and will + // render the story in the same tick. + // However, we can do that safely as the current story is available in `this.storyRenders` + const render = this.storyRenders.find((r) => r.id === storyId); + const story = render?.story || (await this.storyStore.loadStory({ storyId })); + + const argNamesToReset = argNames || Object.keys(this.storyStore.args.get(storyId)); + const updatedArgs = argNamesToReset.reduce((acc, argName) => { + acc[argName] = story.initialArgs[argName]; + return acc; + }, {} as Partial); + + await this.onUpdateArgs({ storyId, updatedArgs }); + } + + // ForceReRender does not include a story id, so we simply must + // re-render all stories in case they are relevant + async onForceReRender() { + await Promise.all(this.storyRenders.map((r) => r.rerender())); + } + + async onForceRemount({ storyId }: { storyId: StoryId }) { + await Promise.all(this.storyRenders.filter((r) => r.id === storyId).map((r) => r.remount())); + } + + // Used by docs' modernInlineRender to render a story to a given element + // Note this short-circuits the `prepare()` phase of the StoryRender, + // main to be consistent with the previous behaviour. In the future, + // we will change it to go ahead and load the story, which will end up being + // "instant", although async. + renderStoryToElement(story: Story, element: HTMLElement) { + const render = new StoryRender( + this.channel, + this.storyStore, + this.renderToDOM, + this.inlineStoryCallbacks(story.id), + story.id, + 'docs', + story + ); + render.renderToElement(element); + + this.storyRenders.push(render); + + return async () => { + await this.teardownRender(render); + }; + } + + async teardownRender( + render: StoryRender | DocsRender, + { viewModeChanged }: { viewModeChanged?: boolean } = {} + ) { + this.storyRenders = this.storyRenders.filter((r) => r !== render); + await render?.teardown({ viewModeChanged }); + } + + // API + async extract(options?: { includeDocsOnly: boolean }) { + if (this.previewEntryError) { + throw this.previewEntryError; + } + + if (!this.storyStore.projectAnnotations) { + // In v6 mode, if your preview.js throws, we never get a chance to initialize the preview + // or store, and the error is simply logged to the browser console. This is the best we can do + throw new Error(dedent`Failed to initialize Storybook. + + Do you have an error in your \`preview.js\`? Check your Storybook's browser console for errors.`); + } + + if (global.FEATURES?.storyStoreV7) { + await this.storyStore.cacheAllCSFFiles(); + } + + return this.storyStore.extract(options); + } + + // UTILITIES + inlineStoryCallbacks(storyId: StoryId) { + return { + showMain: () => {}, + showError: (err: { title: string; description: string }) => + logger.error(`Error rendering docs story (${storyId})`, err), + showException: (err: Error) => logger.error(`Error rendering docs story (${storyId})`, err), + }; + } + + renderPreviewEntryError(reason: string, err: Error) { + this.previewEntryError = err; + logger.error(reason); + logger.error(err); + this.channel.emit(Events.CONFIG_ERROR, err); + } +} diff --git a/lib/preview-web/src/PreviewWeb.tsx b/lib/preview-web/src/PreviewWeb.tsx index 71c450cb1ea..0492c70b0f4 100644 --- a/lib/preview-web/src/PreviewWeb.tsx +++ b/lib/preview-web/src/PreviewWeb.tsx @@ -1,77 +1,52 @@ import deprecate from 'util-deprecate'; import dedent from 'ts-dedent'; import global from 'global'; -import { SynchronousPromise } from 'synchronous-promise'; import Events, { IGNORED_EXCEPTION } from '@storybook/core-events'; import { logger } from '@storybook/client-logger'; -import { addons, Channel } from '@storybook/addons'; import { AnyFramework, StoryId, ProjectAnnotations, Args, Globals } from '@storybook/csf'; import { ModuleImportFn, Selection, Story, - StoryStore, StorySpecifier, StoryIndex, + PromiseLike, WebProjectAnnotations, } from '@storybook/store'; +import { Preview } from './Preview'; + import { UrlStore } from './UrlStore'; import { WebView } from './WebView'; -import { PREPARE_ABORTED, StoryRender } from './StoryRender'; +import { PREPARE_ABORTED, Render, StoryRender } from './StoryRender'; import { DocsRender } from './DocsRender'; -const { window: globalWindow, fetch } = global; +const { window: globalWindow } = global; function focusInInput(event: Event) { const target = event.target as Element; return /input|textarea/i.test(target.tagName) || target.getAttribute('contenteditable') !== null; } -type PromiseLike = Promise | SynchronousPromise; type MaybePromise = Promise | T; -type StoryCleanupFn = () => MaybePromise; - -const STORY_INDEX_PATH = './stories.json'; - -type HTMLStoryRender = StoryRender; - -export class PreviewWeb { - channel: Channel; - - serverChannel?: Channel; +export class PreviewWeb extends Preview { urlStore: UrlStore; - storyStore: StoryStore; - view: WebView; - getStoryIndex?: () => StoryIndex; - - importFn?: ModuleImportFn; - - renderToDOM: WebProjectAnnotations['renderToDOM']; - previewEntryError?: Error; currentSelection: Selection; - currentRender: HTMLStoryRender | DocsRender; - - storyRenders: HTMLStoryRender[] = []; - - previousCleanup: StoryCleanupFn; + currentRender: Render; constructor() { - this.channel = addons.getChannel(); - if (global.FEATURES?.storyStoreV7 && addons.hasServerChannel()) { - this.serverChannel = addons.getServerChannel(); - } - this.view = new WebView(); + super(); + this.view = new WebView(); this.urlStore = new UrlStore(); - this.storyStore = new StoryStore(); + // Add deprecated APIs for back-compat // @ts-ignore this.storyStore.getSelection = deprecate( @@ -84,99 +59,19 @@ export class PreviewWeb { ); } - // INITIALIZATION - - // NOTE: the reason that the preview and store's initialization code is written in a promise - // style and not `async-await`, and the use of `SynchronousPromise`s is in order to allow - // storyshots to immediately call `raw()` on the store without waiting for a later tick. - // (Even simple things like `Promise.resolve()` and `await` involve the callback happening - // in the next promise "tick"). - // See the comment in `storyshots-core/src/api/index.ts` for more detail. - initialize({ - getStoryIndex, - importFn, - getProjectAnnotations, - }: { - // In the case of the v6 store, we can only get the index from the facade *after* - // getProjectAnnotations has been run, thus this slightly awkward approach - getStoryIndex?: () => StoryIndex; - importFn: ModuleImportFn; - getProjectAnnotations: () => MaybePromise>; - }) { - // We save these two on initialization in case `getProjectAnnotations` errors, - // in which case we may need them later when we recover. - this.getStoryIndex = getStoryIndex; - this.importFn = importFn; - - this.setupListeners(); - - return this.getProjectAnnotationsOrRenderError(getProjectAnnotations).then( - (projectAnnotations) => this.initializeWithProjectAnnotations(projectAnnotations) - ); - } - setupListeners() { - globalWindow.onkeydown = this.onKeydown.bind(this); + super.setupListeners(); - this.serverChannel?.on(Events.STORY_INDEX_INVALIDATED, this.onStoryIndexChanged.bind(this)); + globalWindow.onkeydown = this.onKeydown.bind(this); this.channel.on(Events.SET_CURRENT_STORY, this.onSetCurrentStory.bind(this)); this.channel.on(Events.UPDATE_QUERY_PARAMS, this.onUpdateQueryParams.bind(this)); - this.channel.on(Events.UPDATE_GLOBALS, this.onUpdateGlobals.bind(this)); - this.channel.on(Events.UPDATE_STORY_ARGS, this.onUpdateArgs.bind(this)); - this.channel.on(Events.RESET_STORY_ARGS, this.onResetArgs.bind(this)); - this.channel.on(Events.FORCE_RE_RENDER, this.onForceReRender.bind(this)); - this.channel.on(Events.FORCE_REMOUNT, this.onForceRemount.bind(this)); } - getProjectAnnotationsOrRenderError( - getProjectAnnotations: () => MaybePromise> - ): PromiseLike> { - return SynchronousPromise.resolve() - .then(getProjectAnnotations) - .then((projectAnnotations) => { - this.renderToDOM = projectAnnotations.renderToDOM; - if (!this.renderToDOM) { - throw new Error(dedent` - Expected your framework's preset to export a \`renderToDOM\` field. - - Perhaps it needs to be upgraded for Storybook 6.4? - - More info: https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#mainjs-framework-field - `); - } - return projectAnnotations; - }) - .catch((err) => { - // This is an error extracting the projectAnnotations (i.e. evaluating the previewEntries) and - // needs to be show to the user as a simple error - this.renderPreviewEntryError('Error reading preview.js:', err); - throw err; - }); - } - - // If initialization gets as far as project annotations, this function runs. initializeWithProjectAnnotations(projectAnnotations: WebProjectAnnotations) { - this.storyStore.setProjectAnnotations(projectAnnotations); - - this.setInitialGlobals(); - - let storyIndexPromise: PromiseLike; - if (global.FEATURES?.storyStoreV7) { - storyIndexPromise = this.getStoryIndexFromServer(); - } else { - if (!this.getStoryIndex) { - throw new Error('No `getStoryIndex` passed defined in v6 mode'); - } - storyIndexPromise = SynchronousPromise.resolve().then(this.getStoryIndex); - } - - return storyIndexPromise - .then((storyIndex: StoryIndex) => this.initializeWithStoryIndex(storyIndex)) - .catch((err) => { - this.renderPreviewEntryError('Error loading story index:', err); - throw err; - }); + return super + .initializeWithProjectAnnotations(projectAnnotations) + .then(() => this.setInitialGlobals()); } async setInitialGlobals() { @@ -187,35 +82,15 @@ export class PreviewWeb { this.emitGlobals(); } - emitGlobals() { - this.channel.emit(Events.SET_GLOBALS, { - globals: this.storyStore.globals.get() || {}, - globalTypes: this.storyStore.projectAnnotations.globalTypes || {}, - }); - } - - async getStoryIndexFromServer() { - const result = await fetch(STORY_INDEX_PATH); - if (result.status === 200) return result.json() as StoryIndex; - - throw new Error(await result.text()); - } - // If initialization gets as far as the story index, this function runs. - initializeWithStoryIndex(storyIndex: StoryIndex) { - return this.storyStore - .initialize({ - storyIndex, - importFn: this.importFn, - cache: !global.FEATURES?.storyStoreV7, - }) - .then(() => { - if (!global.FEATURES?.storyStoreV7) { - this.channel.emit(Events.SET_STORIES, this.storyStore.getSetStoriesPayload()); - } + initializeWithStoryIndex(storyIndex: StoryIndex): PromiseLike { + return super.initializeWithStoryIndex(storyIndex).then(() => { + if (!global.FEATURES?.storyStoreV7) { + this.channel.emit(Events.SET_STORIES, this.storyStore.getSetStoriesPayload()); + } - return this.selectSpecifiedStory(); - }); + return this.selectSpecifiedStory(); + }); } // Use the selection specifier to choose a story, then render it @@ -269,44 +144,11 @@ export class PreviewWeb { }: { getProjectAnnotations: () => MaybePromise>; }) { - delete this.previewEntryError; + await super.onGetProjectAnnotationsChanged({ getProjectAnnotations }); - const projectAnnotations = await this.getProjectAnnotationsOrRenderError(getProjectAnnotations); - if (!this.storyStore.projectAnnotations) { - await this.initializeWithProjectAnnotations(projectAnnotations); - return; - } - - await this.storyStore.setProjectAnnotations(projectAnnotations); - this.emitGlobals(); this.renderSelection(); } - async onStoryIndexChanged() { - delete this.previewEntryError; - - if (!this.storyStore.projectAnnotations) { - // We haven't successfully set project annotations yet, - // we need to do that before we can do anything else. - return; - } - - try { - const storyIndex = await this.getStoryIndexFromServer(); - - // This is the first time the story index worked, let's load it into the store - if (!this.storyStore.storyIndex) { - await this.initializeWithStoryIndex(storyIndex); - } - - // Update the store with the new stories. - await this.onStoriesChanged({ storyIndex }); - } catch (err) { - this.renderPreviewEntryError('Error loading story index:', err); - throw err; - } - } - // This happens when a glob gets HMR-ed async onStoriesChanged({ importFn, @@ -315,7 +157,8 @@ export class PreviewWeb { importFn?: ModuleImportFn; storyIndex?: StoryIndex; }) { - await this.storyStore.onStoriesChanged({ importFn, storyIndex }); + super.onStoriesChanged({ importFn, storyIndex }); + if (!global.FEATURES?.storyStoreV7) { this.channel.emit(Events.SET_STORIES, await this.storyStore.getSetStoriesPayload()); } @@ -349,22 +192,13 @@ export class PreviewWeb { } async onUpdateGlobals({ globals }: { globals: Globals }) { - this.storyStore.globals.update(globals); - - await Promise.all(this.storyRenders.map((r) => r.rerender())); + super.onUpdateGlobals({ globals }); if (this.currentRender instanceof DocsRender) await this.currentRender.rerender(); - - this.channel.emit(Events.GLOBALS_UPDATED, { - globals: this.storyStore.globals.get(), - initialGlobals: this.storyStore.globals.initialGlobals, - }); } async onUpdateArgs({ storyId, updatedArgs }: { storyId: StoryId; updatedArgs: Args }) { - this.storyStore.args.update(storyId, updatedArgs); - - await Promise.all(this.storyRenders.filter((r) => r.id === storyId).map((r) => r.rerender())); + super.onUpdateArgs({ storyId, updatedArgs }); // NOTE: we aren't checking to see the story args are targetted at the "right" story. // This is because we may render >1 story on the page and there is no easy way to keep track @@ -372,40 +206,6 @@ export class PreviewWeb { // However, in `modernInlineRender`, the individual stories track their own events as they // each call `renderStoryToElement` below. if (this.currentRender instanceof DocsRender) await this.currentRender.rerender(); - - this.channel.emit(Events.STORY_ARGS_UPDATED, { - storyId, - args: this.storyStore.args.get(storyId), - }); - } - - async onResetArgs({ storyId, argNames }: { storyId: string; argNames?: string[] }) { - // NOTE: we have to be careful here and avoid await-ing when updating the current story's args. - // That's because below in `renderStoryToElement` we have also bound to this event and will - // render the story in the same tick. - // However, we can do that safely as the current story is available in `this.currentRender.story` - const { initialArgs } = - storyId === this.currentRender?.id - ? this.currentRender.story - : await this.storyStore.loadStory({ storyId }); - - const argNamesToReset = argNames || Object.keys(this.storyStore.args.get(storyId)); - const updatedArgs = argNamesToReset.reduce((acc, argName) => { - acc[argName] = initialArgs[argName]; - return acc; - }, {} as Partial); - - await this.onUpdateArgs({ storyId, updatedArgs }); - } - - // ForceReRender does not include a story id, so we simply must - // re-render all stories in case they are relevant - async onForceReRender() { - await Promise.all(this.storyRenders.map((r) => r.rerender())); - } - - async onForceRemount({ storyId }: { storyId: StoryId }) { - await Promise.all(this.storyRenders.filter((r) => r.id === storyId).map((r) => r.remount())); } // RENDERING @@ -447,10 +247,7 @@ export class PreviewWeb { lastRender = null; } - const storyRender: PreviewWeb['currentRender'] = new StoryRender< - HTMLElement, - TFramework - >( + const storyRender = new StoryRender( this.channel, this.storyStore, (...args) => { @@ -520,7 +317,7 @@ export class PreviewWeb { } if (selection.viewMode === 'docs' || parameters.docsOnly) { - this.currentRender = storyRender.toDocsRender(); + this.currentRender = DocsRender.fromStoryRender(storyRender); this.currentRender.renderToElement( this.view.prepareForDocs(), this.renderStoryToElement.bind(this) @@ -537,7 +334,7 @@ export class PreviewWeb { // we will change it to go ahead and load the story, which will end up being // "instant", although async. renderStoryToElement(story: Story, element: HTMLElement) { - const render = new StoryRender( + const render = new StoryRender( this.channel, this.storyStore, this.renderToDOM, @@ -556,7 +353,7 @@ export class PreviewWeb { } async teardownRender( - render: HTMLStoryRender | DocsRender, + render: Render, { viewModeChanged }: { viewModeChanged?: boolean } = {} ) { this.storyRenders = this.storyRenders.filter((r) => r !== render); @@ -603,11 +400,8 @@ export class PreviewWeb { } renderPreviewEntryError(reason: string, err: Error) { - this.previewEntryError = err; - logger.error(reason); - logger.error(err); + super.renderPreviewEntryError(reason, err); this.view.showErrorDisplay(err); - this.channel.emit(Events.CONFIG_ERROR, err); } renderMissingStory() { diff --git a/lib/preview-web/src/StoryRender.ts b/lib/preview-web/src/StoryRender.ts index 70d4e311570..d0ede7cc122 100644 --- a/lib/preview-web/src/StoryRender.ts +++ b/lib/preview-web/src/StoryRender.ts @@ -9,7 +9,6 @@ import { import { Story, RenderContext, StoryStore } from '@storybook/store'; import { Channel } from '@storybook/addons'; import { STORY_RENDER_PHASE_CHANGED, STORY_RENDERED } from '@storybook/core-events'; -import { DocsRender } from './DocsRender'; const { AbortController } = global; @@ -41,28 +40,34 @@ export type RenderContextCallbacks = Pick< export const PREPARE_ABORTED = new Error('prepareAborted'); -export class StoryRender< - CanvasElement extends HTMLElement | void, - TFramework extends AnyFramework -> { +export interface Render { + id: StoryId; + story?: Story; + isPreparing: () => boolean; + disableKeyListeners: boolean; + teardown: (options: { viewModeChanged: boolean }) => Promise; + renderToElement: (canvasElement: HTMLElement, renderStoryToElement?: any) => Promise; +} + +export class StoryRender implements Render { public story?: Story; public phase?: RenderPhase; private abortController?: AbortController; - private canvasElement?: CanvasElement; + private canvasElement?: HTMLElement; private notYetRendered = true; public disableKeyListeners = false; constructor( - private channel: Channel, - private store: StoryStore, + public channel: Channel, + public store: StoryStore, private renderToScreen: ( renderContext: RenderContext, - canvasElement: CanvasElement + canvasElement: HTMLElement ) => void | Promise, private callbacks: RenderContextCallbacks, public id: StoryId, @@ -104,7 +109,7 @@ export class StoryRender< } // The two story "renders" are equal and have both loaded the same story - isEqual(other?: StoryRender | DocsRender) { + isEqual(other?: Render) { return other && this.id === other.id && this.story && this.story === other.story; } @@ -116,15 +121,11 @@ export class StoryRender< return ['rendering', 'playing'].includes(this.phase); } - toDocsRender() { - return new DocsRender(this.channel, this.store, this.id, this.story); - } - context() { return this.store.getStoryContext(this.story); } - async renderToElement(canvasElement: CanvasElement) { + async renderToElement(canvasElement: HTMLElement) { this.canvasElement = canvasElement; // FIXME: this comment diff --git a/lib/preview-web/src/index.ts b/lib/preview-web/src/index.ts index d643864f3ab..d3abaf01141 100644 --- a/lib/preview-web/src/index.ts +++ b/lib/preview-web/src/index.ts @@ -1,6 +1,7 @@ // FIXME: breaks builder-vite, remove this in 7.0 export { composeConfigs } from '@storybook/store'; +export { Preview } from './Preview'; export { PreviewWeb } from './PreviewWeb'; export { simulatePageLoad, simulateDOMContentLoaded } from './simulate-pageload'; diff --git a/lib/store/src/StoryStore.ts b/lib/store/src/StoryStore.ts index 04b15b465a4..d76e91fa613 100644 --- a/lib/store/src/StoryStore.ts +++ b/lib/store/src/StoryStore.ts @@ -26,6 +26,7 @@ import type { Path, ExtractOptions, BoundStory, + PromiseLike, StoryIndex, StoryIndexEntry, V2CompatIndexEntry, diff --git a/lib/store/src/types.ts b/lib/store/src/types.ts index cd92d6c1606..d52a0f2588e 100644 --- a/lib/store/src/types.ts +++ b/lib/store/src/types.ts @@ -26,7 +26,7 @@ import type { export type { StoryId, Parameters }; export type Path = string; export type ModuleExports = Record; -type PromiseLike = Promise | SynchronousPromise; +export type PromiseLike = Promise | SynchronousPromise; export type ModuleImportFn = (path: Path) => PromiseLike; export type WebProjectAnnotations = diff --git a/lib/ui/src/app.tsx b/lib/ui/src/app.tsx index 1027819e944..9b0302fee3e 100644 --- a/lib/ui/src/app.tsx +++ b/lib/ui/src/app.tsx @@ -61,13 +61,7 @@ const App = React.memo( if (!width || !height) { content =
; } else if (width < 600) { - content = - ; + content = ; } else { content = ( ( ))} -)) as FunctionComponent<{ active: ActiveTabsType; children: ReactNode, isFullscreen: boolean }>); +)) as FunctionComponent<{ active: ActiveTabsType; children: ReactNode; isFullscreen: boolean }>); Panels.displayName = 'Panels'; const PanelsContainer = styled.div<{ isFullscreen: boolean }>( @@ -101,7 +101,8 @@ const PanelsContainer = styled.div<{ isFullscreen: boolean }>( top: 0, left: 0, width: '100vw', - }, ({ isFullscreen }) => ({ + }, + ({ isFullscreen }) => ({ height: isFullscreen ? '100vh' : 'calc(100% - 40px)', }) ); @@ -192,7 +193,10 @@ class Mobile extends Component { {!options.isFullscreen && ( - this.setState({ active: SIDEBAR })} active={active === SIDEBAR}> + this.setState({ active: SIDEBAR })} + active={active === SIDEBAR} + > Sidebar this.setState({ active: CANVAS })} active={active === CANVAS}> @@ -202,7 +206,10 @@ class Mobile extends Component { ))} {viewMode && !docsOnly ? ( - this.setState({ active: ADDONS })} active={active === ADDONS}> + this.setState({ active: ADDONS })} + active={active === ADDONS} + > Addons ) : null} diff --git a/nx.json b/nx.json index 588fa7bfcf8..3545eaf1005 100644 --- a/nx.json +++ b/nx.json @@ -139,6 +139,10 @@ "@storybook/example-react-ts-webpack4": { "implicitDependencies": [] }, + "@storybook/external-docs": { + "implicitDependencies": [] + }, + "server-kitchen-sink": { "implicitDependencies": [] }, diff --git a/workspace.json b/workspace.json index 010773cb947..f02b363a625 100644 --- a/workspace.json +++ b/workspace.json @@ -161,6 +161,10 @@ "root": "examples/react-ts-webpack4", "type": "library" }, + "@storybook/external-docs": { + "root": "examples/external-docs", + "type": "library" + }, "server-kitchen-sink": { "root": "examples/server-kitchen-sink", "type": "library" diff --git a/yarn.lock b/yarn.lock index 3ea752d6a42..e87284f01cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7735,6 +7735,36 @@ __metadata: languageName: node linkType: hard +"@storybook/external-docs@workspace:examples/external-docs": + version: 0.0.0-use.local + resolution: "@storybook/external-docs@workspace:examples/external-docs" + dependencies: + "@babel/preset-env": ^7.12.11 + "@babel/preset-react": ^7.12.10 + "@babel/preset-typescript": ^7.12.7 + "@storybook/addon-essentials": 6.5.0-alpha.58 + "@storybook/components": 6.5.0-alpha.58 + "@storybook/csf": 0.0.2--canary.87bc651.0 + "@storybook/preview-web": 6.5.0-alpha.58 + "@storybook/react": 6.5.0-alpha.58 + "@storybook/store": 6.5.0-alpha.58 + "@storybook/theming": 6.5.0-alpha.58 + "@testing-library/dom": ^7.31.2 + "@testing-library/user-event": ^13.1.9 + "@types/babel__preset-env": ^7 + "@types/react": ^16.14.23 + "@types/react-dom": ^16.9.14 + cross-env: ^7.0.3 + formik: ^2.2.9 + prop-types: 15.7.2 + react: 16.14.0 + react-dom: 16.14.0 + react-scripts: ^4.0.2 + typescript: ^3.9.7 + webpack: 4 + languageName: unknown + linkType: soft + "@storybook/html@6.5.0-alpha.58, @storybook/html@workspace:*, @storybook/html@workspace:app/html": version: 0.0.0-use.local resolution: "@storybook/html@workspace:app/html"