UNPKG

wix-storybook-utils

Version:

Utilities for automated component documentation within Storybook

416 lines (366 loc) 11.9 kB
import React, { Component } from 'react'; import PropTypes from 'prop-types'; import styles from './styles.scss'; import NO_VALUE_TYPE from './no-value-type'; import categorizeProps from './categorize-props'; import { Option, Preview, Code, Toggle, Input, NumberInput, List, } from './components'; import { Layout, Cell } from '../ui/Layout'; import SectionCollapse from './components/section-collapse'; import matchFuncProp from './utils/match-func-prop'; import stripQuotes from './utils/strip-quotes'; import omit from './utils/omit'; import ensureRegexp from './utils/ensure-regexp'; import HTMLPropsList from './utils/html-props-list.json'; /** * Create a playground for some component, which is suitable for storybook. Given raw `source`, component reference * and, optionally, `componentProps`,`AutoExample` will render: * * * list of all available props with toggles or input fields to control them (with `defaultProps` values applied) * * live preview of `component` * * live code example * * * ### Example: * * ```js * import AutoExample from 'stories/utils/Components/AutoExample'; * import component from 'wix-style-react/MyComponent'; * import source from '!raw-loader!wix-style-react/MyComponent/MyComponent'; // raw string, not something like `export {default} from './MyComponent.js';` * * <AutoExample * source={source} * component={component} * componentProps={{ * value: 'some default value', * onClick: () => console.log('some handler') * }} * /> * ``` */ export default class extends Component { static displayName = 'AutoExample'; static propTypes = { /** * parsed meta object about component. * * Generated by `react-autodocs-utils` * */ parsedSource: PropTypes.object, /** reference to react component */ component: PropTypes.func.isRequired, /** * control default props and their state of component in preview. * * can be either `object` or `function`: * * * `object` - simple javascript object which reflects `component` properties. * * `function` - `(setProps, getProps) => props` * receives `setProps` setter and `getProps` getter. can be used to persist props state and react to event * handlers and must return an object which will be used as new props. For example: * * ```js * <AutoExample * component={ToggleSwitch} * componentProps={(setProps, getProps) => ({ * checked: false, * onChange: () => setProps({ checked: !getProps().checked }) * })} * ``` */ componentProps: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), /** A render function for the component (in the Preview). Typicaly this function can wrap the component in something usefull like a className which is needed. ({component}) => JSXElement */ componentWrapper: PropTypes.func, exampleProps: PropTypes.object, /** when true, display only component preview without interactive props nor code example */ isInteractive: PropTypes.bool, /** currently only `false` possible. later same property shall be used for configuring code example */ codeExample: PropTypes.bool, }; static defaultProps = { source: '', component: () => null, componentProps: {}, parsedSource: {}, exampleProps: {}, isInteractive: true, codeExample: true, }; _initialPropsState = {}; _categorizedProps = []; constructor(props) { super(props); this.parsedComponent = props.parsedSource; this.preparedComponentProps = this.prepareComponentProps( this.props.componentProps, ); this.state = { propsState: { ...(this.props.component.defaultProps || {}), ...this.preparedComponentProps, }, funcValues: {}, funcAnimate: {}, isRtl: false, isDarkBackground: false, forceRemount: 0, }; this._initialPropsState = this.state.propsState; this._categorizedProps = Object.entries( categorizeProps( { ...this.preparedComponentProps, ...this.parsedComponent.props }, this.propsCategories, ), ) .map(([, category]) => category) .sort( ({ order: aOrder = -1 }, { order: bOrder = -1 }) => aOrder - bOrder, ); } resetState = () => this.setState({ propsState: this._initialPropsState }); remountComponent = () => { this.setState({ forceRemount: this.state.forceRemount + 1 }); }; componentWillReceiveProps(nextProps) { this.setState({ propsState: { ...this.state.propsState, ...this.prepareComponentProps(nextProps.componentProps), }, }); } prepareComponentProps = props => typeof props === 'function' ? props( // setState componentProps => this.setState({ propsState: { ...this.state.propsState, ...componentProps }, }), // getState () => this.state.propsState || {}, ) : props; setProp = (key, value) => { if (value === NO_VALUE_TYPE) { // eslint-disable-next-line no-unused-vars const { [key]: deletedKey, ...propsState } = this.state.propsState; this.setState({ propsState }); } else { this.setState({ propsState: { ...this.state.propsState, [key]: value } }); } }; propControllers = [ { types: ['func', /event/, /\) => void$/], controller: ({ propKey }) => { let classNames = styles.example; if (this.state.funcAnimate[propKey]) { classNames += ` ${styles.active}`; setTimeout( () => this.setState({ funcAnimate: { ...this.state.funcAnimate, [propKey]: false }, }), 2000, ); } if (this.props.exampleProps[propKey]) { return ( <div className={classNames}> {this.state.funcValues[propKey] || 'Interaction preview'} </div> ); } }, }, { types: ['bool', 'Boolean'], controller: () => <Toggle />, }, { types: ['enum'], controller: ({ type }) => type && typeof type.value === 'string' ? ( <Input /> ) : ( <List values={type.value.map(({ value }) => stripQuotes(value))} /> ), }, { types: ['string', /ReactText/, 'arrayOf', 'union', 'node', 'ReactNode'], controller: () => <Input />, }, { types: ['number'], controller: () => <NumberInput />, }, ]; getPropControlComponent = (propKey, type = {}) => { if (!matchFuncProp(type.name) && this.props.exampleProps[propKey]) { return <List values={this.props.exampleProps[propKey]} />; } const propControllerCandidate = this.propControllers.find(({ types }) => types.some(t => ensureRegexp(t).test(type.name)), ); return propControllerCandidate && propControllerCandidate.controller ? ( propControllerCandidate.controller({ propKey, type }) ) : ( <Input /> ); }; renderPropControllers = ({ props, allProps }) => Object.entries(props) .filter(([, prop]) => prop) .map(([key, prop]) => ( <Option key={key} {...{ label: key, value: allProps[key], defaultValue: typeof this.props.componentProps === 'function' ? undefined : this.props.componentProps[key], isRequired: prop.required || false, onChange: value => this.setProp(key, value), children: this.getPropControlComponent(key, prop.type), }} /> )); propsCategories = { primary: { title: 'Primary Props', order: 0, isOpen: true, matcher: name => // primary props are all those set in componentProps and exampleProps // except for callback (starts with `on`) and data attributes (starts // with `data`, including data-hook or dataHook) Object.keys({ ...this.props.exampleProps, ...this.preparedComponentProps, }) .filter(n => !['on', 'data'].some(i => n.startsWith(i))) .some(propName => propName === name), }, events: { title: 'Callback Props', order: 1, matcher: name => name.toLowerCase().startsWith('on'), }, html: { title: 'HTML Props', order: 3, matcher: name => HTMLPropsList.some(i => name === i), }, accessibility: { title: 'Accessibility Props', order: 4, matcher: name => name.toLowerCase().startsWith('aria'), }, other: { // miscellaneous props are everything that doesn't fit in other categories title: 'Misc. Props', order: 5, matcher: () => true, }, }; render() { const functionExampleProps = Object.keys(this.props.exampleProps).filter( prop => this.parsedComponent.props[prop] && matchFuncProp(this.parsedComponent.props[prop].type.name), ); const componentProps = { ...this.state.propsState, ...functionExampleProps.reduce((acc, prop) => { acc[prop] = (...rest) => { if (this.state.propsState[prop]) { this.state.propsState[prop](...rest); } this.setState({ funcValues: { ...this.state.funcValues, [prop]: this.props.exampleProps[prop](...rest), }, funcAnimate: { ...this.state.funcAnimate, [prop]: true }, }); }; return acc; }, {}), }; const codeProps = { ...omit(this.state.propsState)(key => key.startsWith('data')), ...functionExampleProps.reduce((acc, key) => { acc[key] = this.props.exampleProps[key]; return acc; }, {}), }; const component = React.createElement(this.props.component, componentProps); const componentToRender = this.props.componentWrapper ? React.cloneElement( this.props.componentWrapper({ component, metadata: this.props.parsedSource, }), { 'data-hook': 'componentWrapper', }, ) : component; if (!this.props.isInteractive) { return componentToRender; } return ( <Layout dataHook="auto-example"> <Cell span={6}> {this._categorizedProps.reduce( (components, { title, isOpen, props }, i) => { const renderablePropControllers = this.renderPropControllers({ props, allProps: componentProps, // TODO: ideally this should not be here }).filter(({ props: { children } }) => children); return renderablePropControllers.length ? components.concat( React.createElement(SectionCollapse, { key: title, title, isOpen: isOpen || i === 0, children: renderablePropControllers, }), ) : components; }, [], )} </Cell> <Preview key={this.state.forceRemount} isRtl={this.state.isRtl} isDarkBackground={this.state.isDarkBackground} onToggleRtl={isRtl => this.setState({ isRtl })} onToggleBackground={isDarkBackground => this.setState({ isDarkBackground }) } onRemountComponent={this.remountComponent} children={componentToRender} /> {this.props.codeExample && ( <Code dataHook="metadata-codeblock" component={React.createElement(this.props.component, codeProps)} /> )} </Layout> ); } }