diff --git a/MIGRATION.md b/MIGRATION.md index ffec720be0e..4795757e5e9 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -7,7 +7,7 @@ - [React Native Async Storage](#react-native-async-storage) - [Deprecate displayName parameter](#deprecate-displayname-parameter) - [Unified docs preset](#unified-docs-preset) - - [Simplified hierarchy separators](#simplified-heirarchy-separators) + - [Simplified hierarchy separators](#simplified-hierarchy-separators) - [From version 5.1.x to 5.2.x](#from-version-51x-to-52x) - [Source-loader](#source-loader) - [Default viewports](#default-viewports) diff --git a/addons/docs/src/blocks/Description.tsx b/addons/docs/src/blocks/Description.tsx index c242d4327d8..5fa8852a375 100644 --- a/addons/docs/src/blocks/Description.tsx +++ b/addons/docs/src/blocks/Description.tsx @@ -1,7 +1,7 @@ -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, useContext } from 'react'; import { Description, DescriptionProps as PureDescriptionProps } from '@storybook/components'; import { DocsContext, DocsContextProps } from './DocsContext'; -import { Component, CURRENT_SELECTION } from './shared'; +import { Component, CURRENT_SELECTION, DescriptionSlot } from './shared'; import { str } from '../lib/docgen/utils'; export enum DescriptionType { @@ -16,9 +16,11 @@ type Notes = string | any; type Info = string | any; interface DescriptionProps { + slot?: DescriptionSlot; of?: '.' | Component; type?: DescriptionType; markdown?: string; + children?: string; } const getNotes = (notes?: Notes) => @@ -29,11 +31,11 @@ const getInfo = (info?: Info) => info && (typeof info === 'string' ? info : str( const noDescription = (component?: Component): string | null => null; export const getDescriptionProps = ( - { of, type, markdown }: DescriptionProps, + { of, type, markdown, children }: DescriptionProps, { parameters }: DocsContextProps ): PureDescriptionProps => { - if (markdown) { - return { markdown }; + if (children || markdown) { + return { markdown: children || markdown }; } const { component, notes, info, docs } = parameters; const { extractComponentDescription = noDescription } = docs || {}; @@ -59,13 +61,19 @@ ${extractComponentDescription(target) || ''} } }; -const DescriptionContainer: FunctionComponent = props => ( - - {context => { - const { markdown } = getDescriptionProps(props, context); - return markdown && ; - }} - -); +const DescriptionContainer: FunctionComponent = props => { + const context = useContext(DocsContext); + const { slot } = props; + let { markdown } = getDescriptionProps(props, context); + if (slot) { + markdown = slot(markdown, context); + } + return markdown ? : null; +}; + +// since we are in the docs blocks, assume default description if for primary component story +DescriptionContainer.defaultProps = { + of: '.', +}; export { DescriptionContainer as Description }; diff --git a/addons/docs/src/blocks/DocsPage.test.ts b/addons/docs/src/blocks/DocsPage.test.ts index af743cf4e15..11294b76bb7 100644 --- a/addons/docs/src/blocks/DocsPage.test.ts +++ b/addons/docs/src/blocks/DocsPage.test.ts @@ -1,4 +1,4 @@ -import { defaultTitleSlot } from './DocsPage'; +import { defaultTitleSlot } from './Title'; describe('defaultTitleSlot', () => { it('showRoots', () => { diff --git a/addons/docs/src/blocks/DocsPage.tsx b/addons/docs/src/blocks/DocsPage.tsx index 63ad8749954..a19a7038867 100644 --- a/addons/docs/src/blocks/DocsPage.tsx +++ b/addons/docs/src/blocks/DocsPage.tsx @@ -1,153 +1,26 @@ import React, { FunctionComponent } from 'react'; - -import { parseKind } from '@storybook/router'; -import { DocsPage as PureDocsPage, PropsTable, PropsTableProps } from '@storybook/components'; -import { H2, H3 } from '@storybook/components/html'; -import { DocsContext } from './DocsContext'; +import { DocsPageProps } from './shared'; +import { Title } from './Title'; +import { Subtitle } from './Subtitle'; import { Description } from './Description'; -import { Story } from './Story'; -import { Preview } from './Preview'; -import { Anchor } from './Anchor'; -import { getPropsTableProps } from './Props'; - -export interface SlotContext { - id?: string; - selectedKind?: string; - selectedStory?: string; - parameters?: any; - storyStore?: any; -} - -export type StringSlot = (context: SlotContext) => string | void; -export type PropsSlot = (context: SlotContext) => PropsTableProps | void; -export type StorySlot = (stories: StoryData[], context: SlotContext) => DocsStoryProps | void; -export type StoriesSlot = (stories: StoryData[], context: SlotContext) => DocsStoryProps[] | void; - -export interface DocsPageProps { - titleSlot: StringSlot; - subtitleSlot: StringSlot; - descriptionSlot: StringSlot; - primarySlot: StorySlot; - propsSlot: PropsSlot; - storiesSlot: StoriesSlot; -} - -interface DocsStoryProps { - id: string; - name: string; - expanded?: boolean; - withToolbar?: boolean; - parameters?: any; -} - -interface StoryData { - id: string; - kind: string; - name: string; - parameters?: any; -} - -export const defaultTitleSlot: StringSlot = ({ selectedKind, parameters }) => { - const { - showRoots, - hierarchyRootSeparator: rootSeparator, - hierarchySeparator: groupSeparator, - } = (parameters && parameters.options) || { - showRoots: undefined, - hierarchyRootSeparator: '|', - hierarchySeparator: /\/|\./, - }; - - let groups; - if (typeof showRoots !== 'undefined') { - groups = selectedKind.split('/'); - } else { - // This covers off all the remaining cases: - // - If the separators were set above, we should use them - // - If they weren't set, we should only should use the old defaults if the kind contains '.' or '|', - // which for this particular splitting is the only case in which it actually matters. - ({ groups } = parseKind(selectedKind, { rootSeparator, groupSeparator })); - } - - return (groups && groups[groups.length - 1]) || selectedKind; -}; - -const defaultSubtitleSlot: StringSlot = ({ parameters }) => - parameters && parameters.componentSubtitle; - -const defaultPropsSlot: PropsSlot = context => getPropsTableProps({ of: '.' }, context); - -const defaultDescriptionSlot: StringSlot = ({ parameters }) => { - const { component, docs } = parameters; - if (!component) { - return null; - } - const { extractComponentDescription } = docs || {}; - return extractComponentDescription && extractComponentDescription(component, parameters); -}; - -const defaultPrimarySlot: StorySlot = stories => stories && stories[0]; -const defaultStoriesSlot: StoriesSlot = stories => { - if (stories && stories.length > 1) { - const [first, ...rest] = stories; - return rest; - } - return null; -}; - -const StoriesHeading = H2; -const StoryHeading = H3; - -const DocsStory: FunctionComponent = ({ - id, - name, - expanded = true, - withToolbar = false, - parameters, -}) => ( - - {expanded && {name}} - {expanded && parameters && parameters.docs && parameters.docs.storyDescription && ( - - )} - - - - -); +import { Primary } from './Primary'; +import { Props } from './Props'; +import { Stories } from './Stories'; export const DocsPage: FunctionComponent = ({ - titleSlot = defaultTitleSlot, - subtitleSlot = defaultSubtitleSlot, - descriptionSlot = defaultDescriptionSlot, - primarySlot = defaultPrimarySlot, - propsSlot = defaultPropsSlot, - storiesSlot = defaultStoriesSlot, + titleSlot, + subtitleSlot, + descriptionSlot, + primarySlot, + propsSlot, + storiesSlot, }) => ( - - {context => { - const title = titleSlot(context) || ''; - const subtitle = subtitleSlot(context) || ''; - const description = descriptionSlot(context) || ''; - const propsTableProps = propsSlot(context); - - const { selectedKind, storyStore } = context; - const componentStories = storyStore - .getStoriesForKind(selectedKind) - .filter((s: any) => !(s.parameters && s.parameters.docs && s.parameters.docs.disable)); - const primary = primarySlot(componentStories, context); - const stories = storiesSlot(componentStories, context); - - return ( - - - {primary && } - {propsTableProps && } - {stories && stories.length > 0 && Stories} - {stories && - stories.map(story => story && )} - - ); - }} - + <> + + <Subtitle slot={subtitleSlot} /> + <Description slot={descriptionSlot} /> + <Primary slot={primarySlot} /> + <Props slot={propsSlot} /> + <Stories slot={storiesSlot} /> + </> ); diff --git a/addons/docs/src/blocks/DocsStory.tsx b/addons/docs/src/blocks/DocsStory.tsx new file mode 100644 index 00000000000..ca6640ad183 --- /dev/null +++ b/addons/docs/src/blocks/DocsStory.tsx @@ -0,0 +1,25 @@ +import React, { FunctionComponent } from 'react'; +import { Subheading } from './Subheading'; +import { DocsStoryProps } from './shared'; +import { Anchor } from './Anchor'; +import { Description } from './Description'; +import { Story } from './Story'; +import { Preview } from './Preview'; + +export const DocsStory: FunctionComponent<DocsStoryProps> = ({ + id, + name, + expanded = true, + withToolbar = false, + parameters, +}) => ( + <Anchor storyId={id}> + {expanded && <Subheading>{name}</Subheading>} + {expanded && parameters && parameters.docs && parameters.docs.storyDescription && ( + <Description markdown={parameters.docs.storyDescription} /> + )} + <Preview withToolbar={withToolbar}> + <Story id={id} /> + </Preview> + </Anchor> +); diff --git a/addons/docs/src/blocks/Heading.tsx b/addons/docs/src/blocks/Heading.tsx new file mode 100644 index 00000000000..13aefc91fe6 --- /dev/null +++ b/addons/docs/src/blocks/Heading.tsx @@ -0,0 +1,7 @@ +import React, { FunctionComponent } from 'react'; +import { H2 } from '@storybook/components/html'; + +interface HeadingProps { + children: JSX.Element | string; +} +export const Heading: FunctionComponent<HeadingProps> = ({ children }) => <H2>{children}</H2>; diff --git a/addons/docs/src/blocks/Primary.tsx b/addons/docs/src/blocks/Primary.tsx new file mode 100644 index 00000000000..fe2b764fcaa --- /dev/null +++ b/addons/docs/src/blocks/Primary.tsx @@ -0,0 +1,16 @@ +import React, { useContext, FunctionComponent } from 'react'; +import { DocsContext } from './DocsContext'; +import { DocsStory } from './DocsStory'; +import { getDocsStories } from './utils'; +import { StorySlot } from './shared'; + +interface PrimaryProps { + slot?: StorySlot; +} + +export const Primary: FunctionComponent<PrimaryProps> = ({ slot }) => { + const context = useContext(DocsContext); + const componentStories = getDocsStories(context); + const story = slot ? slot(componentStories, context) : componentStories && componentStories[0]; + return story ? <DocsStory {...story} expanded={false} withToolbar /> : null; +}; diff --git a/addons/docs/src/blocks/Props.tsx b/addons/docs/src/blocks/Props.tsx index b204bf70200..9163cbf6a94 100644 --- a/addons/docs/src/blocks/Props.tsx +++ b/addons/docs/src/blocks/Props.tsx @@ -1,15 +1,22 @@ -import React, { FunctionComponent } from 'react'; -import { PropsTable, PropsTableError, PropsTableProps, PropDef } from '@storybook/components'; +import React, { FunctionComponent, useContext } from 'react'; import { isNil } from 'lodash'; + +import { PropsTable, PropsTableError, PropsTableProps, TabsState } from '@storybook/components'; import { DocsContext, DocsContextProps } from './DocsContext'; -import { Component, CURRENT_SELECTION } from './shared'; +import { Component, PropsSlot, CURRENT_SELECTION } from './shared'; +import { getComponentName } from './utils'; + import { PropsExtractor } from '../lib/docgen/types'; import { extractProps as reactExtractProps } from '../frameworks/react/extractProps'; import { extractProps as vueExtractProps } from '../frameworks/vue/extractProps'; interface PropsProps { exclude?: string[]; - of: '.' | Component; + of?: '.' | Component; + components?: { + [label: string]: Component; + }; + slot?: PropsSlot; } // FIXME: remove in SB6.0 & require config @@ -24,24 +31,22 @@ const inferPropsExtractor = (framework: string): PropsExtractor | null => { } }; -export const getPropsTableProps = ( - { exclude, of }: PropsProps, +export const getComponentProps = ( + component: Component, + { exclude }: PropsProps, { parameters }: DocsContextProps ): PropsTableProps => { + if (!component) { + return null; + } try { const params = parameters || {}; - const { component, framework = null } = params; - - const target = of === CURRENT_SELECTION ? component : of; - if (!target) { - throw new Error(PropsTableError.NO_COMPONENT); - } + const { framework = null } = params; const { extractProps = inferPropsExtractor(framework) } = params.docs || {}; if (!extractProps) { throw new Error(PropsTableError.PROPS_UNSUPPORTED); } - let { rows } = extractProps(target); if (!isNil(exclude)) { rows = rows.filter((row: PropDef) => !exclude.includes(row.name)); @@ -53,13 +58,70 @@ export const getPropsTableProps = ( } }; -const PropsContainer: FunctionComponent<PropsProps> = props => ( - <DocsContext.Consumer> - {context => { - const propsTableProps = getPropsTableProps(props, context); - return <PropsTable {...propsTableProps} />; - }} - </DocsContext.Consumer> -); +export const getComponent = (props: PropsProps = {}, context: DocsContextProps): Component => { + const { of } = props; + const { parameters = {} } = context; + const { component } = parameters; + + const target = of === CURRENT_SELECTION ? component : of; + if (!target) { + if (of === CURRENT_SELECTION) { + return null; + } + throw new Error(PropsTableError.NO_COMPONENT); + } + return target; +}; + +const PropsContainer: FunctionComponent<PropsProps> = props => { + const context = useContext(DocsContext); + const { slot, components } = props; + const { + parameters: { subcomponents }, + } = context; + + let allComponents = components; + if (!allComponents) { + const main = getComponent(props, context); + const mainLabel = getComponentName(main); + const mainProps = slot ? slot(context, main) : getComponentProps(main, props, context); + + if (!subcomponents || typeof subcomponents !== 'object') { + return mainProps && <PropsTable {...mainProps} />; + } + + allComponents = { [mainLabel]: main, ...subcomponents }; + } + + const tabs: { label: string; table: PropsTableProps }[] = []; + Object.entries(allComponents).forEach(([label, component]) => { + tabs.push({ + label, + table: slot ? slot(context, component) : getComponentProps(component, props, context), + }); + }); + + return ( + <TabsState> + {tabs.map(({ label, table }) => { + if (!table) { + return null; + } + const id = `prop_table_div_${label}`; + return ( + <div key={id} id={id} title={label}> + {({ active }: { active: boolean }) => + active ? <PropsTable key={`prop_table_${label}`} {...table} /> : null + } + </div> + ); + })} + </TabsState> + ); +}; + +PropsContainer.defaultProps = { + of: '.', +}; export { PropsContainer as Props }; diff --git a/addons/docs/src/blocks/Stories.tsx b/addons/docs/src/blocks/Stories.tsx new file mode 100644 index 00000000000..c1b585e82db --- /dev/null +++ b/addons/docs/src/blocks/Stories.tsx @@ -0,0 +1,33 @@ +import React, { useContext, FunctionComponent } from 'react'; +import { DocsContext } from './DocsContext'; +import { DocsStory } from './DocsStory'; +import { Heading } from './Heading'; +import { getDocsStories } from './utils'; +import { StoriesSlot, DocsStoryProps } from './shared'; + +interface StoriesProps { + slot?: StoriesSlot; + title?: JSX.Element | string; +} + +export const Stories: FunctionComponent<StoriesProps> = ({ slot, title }) => { + const context = useContext(DocsContext); + const componentStories = getDocsStories(context); + + const stories: DocsStoryProps[] = slot + ? slot(componentStories, context) + : componentStories && componentStories.slice(1); + if (!stories) { + return null; + } + return ( + <> + <Heading>{title}</Heading> + {stories.map(story => story && <DocsStory key={story.id} {...story} expanded />)} + </> + ); +}; + +Stories.defaultProps = { + title: 'Stories', +}; diff --git a/addons/docs/src/blocks/Subheading.tsx b/addons/docs/src/blocks/Subheading.tsx new file mode 100644 index 00000000000..0cda996ffc2 --- /dev/null +++ b/addons/docs/src/blocks/Subheading.tsx @@ -0,0 +1,7 @@ +import React, { FunctionComponent } from 'react'; +import { H3 } from '@storybook/components/html'; + +interface SubheadingProps { + children: JSX.Element | string; +} +export const Subheading: FunctionComponent<SubheadingProps> = ({ children }) => <H3>{children}</H3>; diff --git a/addons/docs/src/blocks/Subtitle.tsx b/addons/docs/src/blocks/Subtitle.tsx new file mode 100644 index 00000000000..595bac6452f --- /dev/null +++ b/addons/docs/src/blocks/Subtitle.tsx @@ -0,0 +1,19 @@ +import React, { useContext, FunctionComponent } from 'react'; +import { Subtitle as PureSubtitle } from '@storybook/components'; +import { DocsContext } from './DocsContext'; +import { StringSlot } from './shared'; + +interface SubtitleProps { + slot?: StringSlot; + children?: JSX.Element | string; +} + +export const Subtitle: FunctionComponent<SubtitleProps> = ({ slot, children }) => { + const context = useContext(DocsContext); + const { parameters } = context; + let text: JSX.Element | string = children; + if (!text) { + text = slot ? slot(context) : parameters && parameters.componentSubtitle; + } + return text ? <PureSubtitle className="sbdocs-subtitle">{text}</PureSubtitle> : null; +}; diff --git a/addons/docs/src/blocks/Title.tsx b/addons/docs/src/blocks/Title.tsx new file mode 100644 index 00000000000..a670600d6e2 --- /dev/null +++ b/addons/docs/src/blocks/Title.tsx @@ -0,0 +1,48 @@ +import React, { useContext, FunctionComponent } from 'react'; +import { parseKind } from '@storybook/router'; +import { Title as PureTitle } from '@storybook/components'; +import { DocsContext } from './DocsContext'; +import { StringSlot } from './shared'; + +interface TitleProps { + slot?: StringSlot; + children?: JSX.Element | string; +} +export const defaultTitleSlot: StringSlot = ({ selectedKind, parameters }) => { + const { + showRoots, + hierarchyRootSeparator: rootSeparator, + hierarchySeparator: groupSeparator, + } = (parameters && parameters.options) || { + showRoots: undefined, + hierarchyRootSeparator: '|', + hierarchySeparator: /\/|\./, + }; + + let groups; + if (typeof showRoots !== 'undefined') { + groups = selectedKind.split('/'); + } else { + // This covers off all the remaining cases: + // - If the separators were set above, we should use them + // - If they weren't set, we should only should use the old defaults if the kind contains '.' or '|', + // which for this particular splitting is the only case in which it actually matters. + ({ groups } = parseKind(selectedKind, { rootSeparator, groupSeparator })); + } + + return (groups && groups[groups.length - 1]) || selectedKind; +}; + +export const Title: FunctionComponent<TitleProps> = ({ slot, children }) => { + const context = useContext(DocsContext); + const { selectedKind, parameters } = context; + let text: JSX.Element | string = children; + if (!text) { + if (slot) { + text = slot(context); + } else { + text = defaultTitleSlot({ selectedKind, parameters }); + } + } + return text ? <PureTitle className="sbdocs-title">{text}</PureTitle> : null; +}; diff --git a/addons/docs/src/blocks/index.ts b/addons/docs/src/blocks/index.ts index 4e68cf50276..e4afb011602 100644 --- a/addons/docs/src/blocks/index.ts +++ b/addons/docs/src/blocks/index.ts @@ -5,12 +5,21 @@ export * from './Description'; export * from './DocsContext'; export * from './DocsPage'; export * from './DocsContainer'; +export * from './DocsStory'; +export * from './Heading'; export * from './Meta'; export * from './Preview'; +export * from './Primary'; export * from './Props'; export * from './Source'; +export * from './Stories'; export * from './Story'; +export * from './Subheading'; +export * from './Subtitle'; +export * from './Title'; export * from './Wrapper'; +export * from './shared'; + // helper function for MDX export const makeStoryFn = (val: any) => (typeof val === 'function' ? val : () => val); diff --git a/addons/docs/src/blocks/shared.ts b/addons/docs/src/blocks/shared.ts index 61d2a7f93f5..baab2861c30 100644 --- a/addons/docs/src/blocks/shared.ts +++ b/addons/docs/src/blocks/shared.ts @@ -1,2 +1,41 @@ +import { PropsTableProps } from '@storybook/components'; + export const CURRENT_SELECTION = '.'; + export type Component = any; + +export interface StoryData { + id?: string; + kind?: string; + name?: string; + parameters?: any; +} + +export type DocsStoryProps = StoryData & { + expanded?: boolean; + withToolbar?: boolean; +}; + +export interface SlotContext { + id?: string; + selectedKind?: string; + selectedStory?: string; + parameters?: any; + storyStore?: any; +} + +export type StringSlot = (context: SlotContext) => string; +export type DescriptionSlot = (description: string, context: SlotContext) => string; +export type PropsSlot = (context: SlotContext, component: Component) => PropsTableProps; +export type StorySlot = (stories: StoryData[], context: SlotContext) => DocsStoryProps; + +export type StoriesSlot = (stories: StoryData[], context: SlotContext) => DocsStoryProps[]; + +export interface DocsPageProps { + titleSlot?: StringSlot; + subtitleSlot?: StringSlot; + descriptionSlot?: DescriptionSlot; + primarySlot?: StorySlot; + propsSlot?: PropsSlot; + storiesSlot?: StoriesSlot; +} diff --git a/addons/docs/src/blocks/utils.ts b/addons/docs/src/blocks/utils.ts new file mode 100644 index 00000000000..316c6bae4a9 --- /dev/null +++ b/addons/docs/src/blocks/utils.ts @@ -0,0 +1,34 @@ +/* eslint-disable no-underscore-dangle */ +import { DocsContextProps } from './DocsContext'; +import { StoryData, Component } from './shared'; + +export const getDocsStories = (context: DocsContextProps): StoryData[] => { + const { storyStore, selectedKind } = context; + return storyStore + .getStoriesForKind(selectedKind) + .filter((s: any) => !(s.parameters && s.parameters.docs && s.parameters.docs.disable)); +}; + +const titleCase = (str: string): string => + str + .split('-') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); + +export const getComponentName = (component: Component): string => { + if (!component) { + return undefined; + } + + if (typeof component === 'string') { + if (component.includes('-')) { + return titleCase(component); + } + return component; + } + if (component.__docgenInfo && component.__docgenInfo.displayName) { + return component.__docgenInfo.displayName; + } + + return component.name; +}; diff --git a/addons/docs/src/frameworks/react/propTypes/createDefaultValue.ts b/addons/docs/src/frameworks/react/propTypes/createDefaultValue.ts index 3ca17796955..35164da7e0e 100644 --- a/addons/docs/src/frameworks/react/propTypes/createDefaultValue.ts +++ b/addons/docs/src/frameworks/react/propTypes/createDefaultValue.ts @@ -65,7 +65,7 @@ function generateFunc({ inferedType, ast }: InspectionResult): PropDefaultValue } // All elements are JSX elements. -// JSX elements cannot are not supported by escodegen. +// JSX elements are not supported by escodegen. function generateElement( defaultValue: string, inspectionResult: InspectionResult diff --git a/addons/docs/src/frameworks/react/propTypes/handleProp.test.ts b/addons/docs/src/frameworks/react/propTypes/handleProp.test.ts index 2ac447e41e5..a77ebf694aa 100644 --- a/addons/docs/src/frameworks/react/propTypes/handleProp.test.ts +++ b/addons/docs/src/frameworks/react/propTypes/handleProp.test.ts @@ -47,15 +47,15 @@ function extractPropDef(component: Component): PropDef { } describe('enhancePropTypesProp', () => { - function createTestComponent(docgenInfo: Partial<DocgenInfo>): Component { - return createComponent({ - docgenInfo: { - ...createDocgenProp({ name: 'prop', ...docgenInfo }), - }, - }); - } - describe('type', () => { + function createTestComponent(docgenInfo: Partial<DocgenInfo>): Component { + return createComponent({ + docgenInfo: { + ...createDocgenProp({ name: 'prop', ...docgenInfo }), + }, + }); + } + describe('custom', () => { describe('when raw value is available', () => { it('should support literal', () => { @@ -82,9 +82,7 @@ describe('enhancePropTypesProp', () => { const { type } = extractPropDef(component); - const expectedSummary = `{ - text: string - }`; + const expectedSummary = '{ text: string }'; expect(type.summary.replace(/\s/g, '')).toBe(expectedSummary.replace(/\s/g, '')); expect(type.detail).toBeUndefined(); @@ -707,10 +705,185 @@ describe('enhancePropTypesProp', () => { }); }); }); + + describe('defaultValue', () => { + function createTestComponent(defaultValue: string): Component { + return createComponent({ + docgenInfo: { + ...createDocgenProp({ + name: 'prop', + type: { name: 'custom' }, + defaultValue: { value: defaultValue }, + }), + }, + }); + } + + it('should support short object', () => { + const component = createTestComponent("{ foo: 'foo', bar: 'bar' }"); + + const { defaultValue } = extractPropDef(component); + + const expectedSummary = "{ foo: 'foo', bar: 'bar' }"; + + expect(defaultValue.summary.replace(/\s/g, '')).toBe(expectedSummary.replace(/\s/g, '')); + expect(defaultValue.detail).toBeUndefined(); + }); + + it('should support long object', () => { + const component = createTestComponent("{ foo: 'foo', bar: 'bar', another: 'another' }"); + + const { defaultValue } = extractPropDef(component); + + expect(defaultValue.summary).toBe('object'); + + const expectedDetail = `{ + foo: 'foo', + bar: 'bar', + another: 'another' + }`; + + expect(defaultValue.detail.replace(/\s/g, '')).toBe(expectedDetail.replace(/\s/g, '')); + }); + + it('should support short function', () => { + const component = createTestComponent('() => {}'); + + const { defaultValue } = extractPropDef(component); + + expect(defaultValue.summary).toBe('() => {}'); + expect(defaultValue.detail).toBeUndefined(); + }); + + it('should support long function', () => { + const component = createTestComponent( + '(foo, bar) => {\n const concat = foo + bar;\n const append = concat + " hey!";\n \n return append;\n}' + ); + + const { defaultValue } = extractPropDef(component); + + expect(defaultValue.summary).toBe('func'); + + const expectedDetail = `(foo, bar) => { + const concat = foo + bar; + const append = concat + ' hey!'; + return append + }`; + + expect(defaultValue.detail.replace(/\s/g, '')).toBe(expectedDetail.replace(/\s/g, '')); + }); + + it('should use the name of function when available and indicate that args are present', () => { + const component = createTestComponent('function concat(a, b) {\n return a + b;\n}'); + + const { defaultValue } = extractPropDef(component); + + expect(defaultValue.summary).toBe('concat( ... )'); + + const expectedDetail = `function concat(a, b) { + return a + b + }`; + + expect(defaultValue.detail.replace(/\s/g, '')).toBe(expectedDetail.replace(/\s/g, '')); + }); + + it('should use the name of function when available', () => { + const component = createTestComponent('function hello() {\n return "hello";\n}'); + + const { defaultValue } = extractPropDef(component); + + expect(defaultValue.summary).toBe('hello()'); + + const expectedDetail = `function hello() { + return 'hello' + }`; + + expect(defaultValue.detail.replace(/\s/g, '')).toBe(expectedDetail.replace(/\s/g, '')); + }); + + it('should support short element', () => { + const component = createTestComponent('<div>Hey!</div>'); + + const { defaultValue } = extractPropDef(component); + + expect(defaultValue.summary).toBe('<div>Hey!</div>'); + expect(defaultValue.detail).toBeUndefined(); + }); + + it('should support long element', () => { + const component = createTestComponent( + '() => {\n return <div>Inlined FunctionnalComponent!</div>;\n}' + ); + + const { defaultValue } = extractPropDef(component); + + expect(defaultValue.summary).toBe('element'); + + const expectedDetail = `() => { + return <div>Inlined FunctionnalComponent!</div>; + }`; + + expect(defaultValue.detail.replace(/\s/g, '')).toBe(expectedDetail.replace(/\s/g, '')); + }); + + it("should use the name of the React component when it's available", () => { + const component = createTestComponent( + 'function InlinedFunctionalComponent() {\n return <div>Inlined FunctionnalComponent!</div>;\n}' + ); + + const { defaultValue } = extractPropDef(component); + + expect(defaultValue.summary).toBe('<InlinedFunctionalComponent />'); + + const expectedDetail = `function InlinedFunctionalComponent() { + return <div>Inlined FunctionnalComponent!</div>; + }`; + + expect(defaultValue.detail.replace(/\s/g, '')).toBe(expectedDetail.replace(/\s/g, '')); + }); + + it('should not use the name of an HTML element', () => { + const component = createTestComponent('<div>Hey!</div>'); + + const { defaultValue } = extractPropDef(component); + + expect(defaultValue.summary).not.toBe('<div />'); + }); + + it('should support short array', () => { + const component = createTestComponent('[1]'); + + const { defaultValue } = extractPropDef(component); + + expect(defaultValue.summary).toBe('[1]'); + expect(defaultValue.detail).toBeUndefined(); + }); + + it('should support long array', () => { + const component = createTestComponent( + '[\n {\n thing: {\n id: 2,\n func: () => {},\n arr: [],\n },\n },\n]' + ); + + const { defaultValue } = extractPropDef(component); + + expect(defaultValue.summary).toBe('array'); + + const expectedDetail = `[{ + thing: { + id: 2, + func: () => { + }, + arr: [] + } + }]`; + + expect(defaultValue.detail.replace(/\s/g, '')).toBe(expectedDetail.replace(/\s/g, '')); + }); + }); }); describe('enhancePropTypesProps', () => { - it('keep the original definition order', () => { + it('should keep the original definition order', () => { const component = createComponent({ propTypes: { foo: PropTypes.string, @@ -751,4 +924,32 @@ describe('enhancePropTypesProps', () => { expect(props[2].name).toBe('bar'); expect(props[3].name).toBe('endWithDefaultValue'); }); + + it('should not include @ignore props', () => { + const component = createComponent({ + propTypes: { + foo: PropTypes.string, + bar: PropTypes.string, + }, + docgenInfo: { + ...createDocgenProp({ + name: 'foo', + type: { name: 'string' }, + }), + ...createDocgenProp({ + name: 'bar', + type: { name: 'string' }, + description: '@ignore', + }), + }, + }); + + const props = enhancePropTypesProps( + extractPropsFromDocgen(component, DOCGEN_SECTION), + component + ); + + expect(props.length).toBe(1); + expect(props[0].name).toBe('foo'); + }); }); diff --git a/addons/docs/src/frameworks/react/propTypes/sortProps.ts b/addons/docs/src/frameworks/react/propTypes/sortProps.ts index 375ce9c5e04..9061ee0ded5 100644 --- a/addons/docs/src/frameworks/react/propTypes/sortProps.ts +++ b/addons/docs/src/frameworks/react/propTypes/sortProps.ts @@ -2,7 +2,7 @@ import { PropDef } from '@storybook/components'; import { isNil } from 'lodash'; import { Component } from '../../../blocks/shared'; -// react-docgen doesn't returned the props in the order they were defined in the "propTypes" of the component. +// react-docgen doesn't returned the props in the order they were defined in the "propTypes" object of the component. // This function re-order them by their original definition order. export function keepOriginalDefinitionOrder( extractedProps: PropDef[], @@ -12,7 +12,9 @@ export function keepOriginalDefinitionOrder( const { propTypes } = component; if (!isNil(propTypes)) { - return Object.keys(propTypes).map(x => extractedProps.find(y => y.name === x)); + return Object.keys(propTypes) + .map(x => extractedProps.find(y => y.name === x)) + .filter(x => x); } return extractedProps; diff --git a/addons/docs/src/mdx/__testfixtures__/component-id.output.snapshot b/addons/docs/src/mdx/__testfixtures__/component-id.output.snapshot index c3cf1c46ffa..55814119d63 100644 --- a/addons/docs/src/mdx/__testfixtures__/component-id.output.snapshot +++ b/addons/docs/src/mdx/__testfixtures__/component-id.output.snapshot @@ -39,7 +39,7 @@ componentNotes.story.parameters = { mdxSource: '<Button>Component notes</Button> const componentMeta = { title: 'Button', id: 'button-id', includeStories: ['componentNotes'] }; -const mdxStoryNameToId = { 'component notes': 'button--component-notes' }; +const mdxStoryNameToId = { 'component notes': 'button-id--component-notes' }; componentMeta.parameters = componentMeta.parameters || {}; componentMeta.parameters.docs = { diff --git a/addons/docs/src/mdx/mdx-compiler-plugin.js b/addons/docs/src/mdx/mdx-compiler-plugin.js index 1d489ce1ff0..f5ca25aabfb 100644 --- a/addons/docs/src/mdx/mdx-compiler-plugin.js +++ b/addons/docs/src/mdx/mdx-compiler-plugin.js @@ -298,11 +298,11 @@ function extractExports(node, options) { } metaExport.includeStories = JSON.stringify(includeStories); - const { title } = metaExport; + const { title, id: componentId } = metaExport; const mdxStoryNameToId = Object.entries(context.storyNameToKey).reduce( (acc, [storyName, storyKey]) => { if (title) { - acc[storyName] = toId(title, storyNameFromExport(storyKey)); + acc[storyName] = toId(componentId || title, storyNameFromExport(storyKey)); } return acc; }, diff --git a/examples/official-storybook/components/ButtonGroup.js b/examples/official-storybook/components/ButtonGroup.js new file mode 100644 index 00000000000..01953ea10d9 --- /dev/null +++ b/examples/official-storybook/components/ButtonGroup.js @@ -0,0 +1,17 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +/** ButtonGroup component description from docgen */ +export const ButtonGroup = ({ background, children }) => ( + <div style={{ background }}>{children}</div> +); + +ButtonGroup.defaultProps = { + background: '#ff0', + children: null, +}; + +ButtonGroup.propTypes = { + background: PropTypes.string, + children: PropTypes.arrayOf(PropTypes.element), +}; diff --git a/examples/official-storybook/preview.js b/examples/official-storybook/preview.js index b4cacdbb4f3..f536535ede7 100644 --- a/examples/official-storybook/preview.js +++ b/examples/official-storybook/preview.js @@ -56,12 +56,6 @@ addParameters({ { name: 'dark', value: '#222222' }, ], docs: { - // eslint-disable-next-line react/prop-types - page: ({ context }) => ( - <DocsPage - context={context} - subtitleSlot={({ selectedKind }) => `Subtitle: ${selectedKind}`} - /> - ), + page: () => <DocsPage subtitleSlot={({ selectedKind }) => `Subtitle: ${selectedKind}`} />, }, }); diff --git a/examples/official-storybook/stories/addon-docs/addon-docs-blocks.stories.js b/examples/official-storybook/stories/addon-docs/addon-docs-blocks.stories.js new file mode 100644 index 00000000000..1f614050bb0 --- /dev/null +++ b/examples/official-storybook/stories/addon-docs/addon-docs-blocks.stories.js @@ -0,0 +1,176 @@ +import React from 'react'; +import { + Title, + Subtitle, + Description, + Primary, + Props, + Stories, +} from '@storybook/addon-docs/blocks'; +import { DocgenButton } from '../../components/DocgenButton'; +import BaseButton from '../../components/BaseButton'; +import { ButtonGroup } from '../../components/ButtonGroup'; + +export default { + title: 'Addons/Docs/stories docs bocks', + component: DocgenButton, + parameters: { + docs: { + page: () => ( + <> + <Title /> + <Subtitle /> + <Description /> + <Primary /> + <Props /> + <Stories /> + </> + ), + }, + }, +}; + +export const defDocsPage = () => <div>Default docs page</div>; + +export const smallDocsPage = () => <div>Just primary story, </div>; +smallDocsPage.story = { + parameters: { + docs: { + page: () => ( + <> + <Title /> + <Primary /> + </> + ), + }, + }, +}; + +export const checkBoxProps = () => <div>Primary props displayed with a check box </div>; +checkBoxProps.story = { + parameters: { + docs: { + page: () => { + const [showProps, setShowProps] = React.useState(false); + return ( + <> + <Title /> + <Subtitle /> + <Description /> + <Primary /> + <label> + <input + type="checkbox" + checked={showProps} + onChange={() => setShowProps(!showProps)} + /> + <span>display props</span> + </label> + {showProps && <Props />} + </> + ); + }, + }, + }, +}; + +export const customLabels = () => <div>Display custom title, Subtitle, Description</div>; +customLabels.story = { + parameters: { + docs: { + page: () => ( + <> + <Title>Custom title + Custom sub title + Custom description + + + + + ), + }, + }, +}; + +export const customStoriesFilter = () =>
Displays ALL stories (not excluding first one)
; +customStoriesFilter.story = { + parameters: { + docs: { + page: () => ( + <> + stories} /> + + ), + }, + }, +}; + +export const descriptionSlot = () =>
Adds markdown to the description
; +descriptionSlot.story = { + parameters: { + docs: { + page: () => ( + <> + `${description}`} /> + + ), + }, + }, +}; + +export const multipleComponents = () => ( + + + + + +); + +multipleComponents.story = { + name: 'Many Components', + parameters: { + component: ButtonGroup, + subcomponents: { + 'Docgen Button': DocgenButton, + 'Base Button': BaseButton, + }, + docs: { + page: () => ( + <> + + <Subtitle /> + <Description /> + <Primary slot={stories => stories.find(story => story.story === 'Many Components')} /> + <Props /> + </> + ), + }, + }, +}; + +export const componentsProps = () => <div>Display multiple prop tables in tabs</div>; +componentsProps.story = { + subcomponents: { + 'Docgen Button': DocgenButton, + 'Base Button': BaseButton, + }, + parameters: { + docs: { + page: () => ( + <> + <Title>Multiple prop tables + + Here's what happens when your component has some related components + + + + ), + }, + }, +}; diff --git a/lib/components/src/blocks/DocsPage.stories.tsx b/lib/components/src/blocks/DocsPage.stories.tsx index 5a12b1d574b..6b8d7121c65 100644 --- a/lib/components/src/blocks/DocsPage.stories.tsx +++ b/lib/components/src/blocks/DocsPage.stories.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { DocsPage, DocsWrapper, DocsContent } from './DocsPage'; +import { Title, Subtitle, DocsWrapper, DocsContent } from './DocsPage'; import * as storyStories from './Story.stories'; import * as previewStories from './Preview.stories'; import * as propsTableStories from './PropsTable/PropsTable.stories'; @@ -8,7 +8,7 @@ import * as descriptionStories from './Description.stories'; export default { title: 'Docs/DocsPage', - component: DocsPage, + component: DocsWrapper, decorators: [ storyFn => ( @@ -19,49 +19,53 @@ export default { }; export const withSubtitle = () => ( - + <> + DocsPage + + What the DocsPage looks like. Meant to be QAed in Canvas tab not in Docs tab. + {descriptionStories.text()} {previewStories.single()} {propsTableStories.normal()} {sourceStories.jsx()} - + ); withSubtitle.story = { name: 'with subtitle' }; export const empty = () => ( - + <> {storyStories.error()} {propsTableStories.error()} {sourceStories.sourceUnavailable()} - + ); export const noText = () => ( - + <> + no text {previewStories.single()} {propsTableStories.normal()} {sourceStories.jsx()} - + ); noText.story = { name: 'no text' }; export const text = () => ( - + <> + Sensorium {descriptionStories.text()} {previewStories.single()} {propsTableStories.normal()} {sourceStories.jsx()} - + ); export const markdown = () => ( - + <> + markdown {descriptionStories.markdown()} {previewStories.single()} {propsTableStories.normal()} {sourceStories.jsx()} - + ); diff --git a/lib/components/src/blocks/DocsPage.tsx b/lib/components/src/blocks/DocsPage.tsx index c577f9655b8..42805469732 100644 --- a/lib/components/src/blocks/DocsPage.tsx +++ b/lib/components/src/blocks/DocsPage.tsx @@ -11,7 +11,7 @@ export interface DocsPageProps { subtitle?: string; } -const Title = styled.h1<{}>(withReset, ({ theme }: { theme: Theme }) => ({ +export const Title = styled.h1<{}>(withReset, ({ theme }: { theme: Theme }) => ({ color: theme.color.defaultText, fontSize: theme.typography.size.m3, fontWeight: theme.typography.weight.black, @@ -24,7 +24,7 @@ const Title = styled.h1<{}>(withReset, ({ theme }: { theme: Theme }) => ({ }, })); -const Subtitle = styled.h2<{}>(withReset, ({ theme }: { theme: Theme }) => ({ +export const Subtitle = styled.h2<{}>(withReset, ({ theme }: { theme: Theme }) => ({ fontWeight: theme.typography.weight.regular, fontSize: theme.typography.size.s3, lineHeight: '20px', @@ -62,18 +62,3 @@ export const DocsPageWrapper: FunctionComponent = ({ children }) => ( {children} ); - -/** - * An out-of-the box documentation page for components that shows the - * title & subtitle and a collection of blocks including `Description`, - * and `Preview`s for each of the component's stories. - */ -const DocsPage: FunctionComponent = ({ title, subtitle, children }) => ( - <> - {title && {title}} - {subtitle && {subtitle}} - {children} - -); - -export { DocsPage };