/* * The MIT License (MIT) * * Copyright (c) 2015 - present Instructure, Inc. * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import { nanoid } from 'nanoid' import { generatePropCombinations } from './generatePropCombinations' import React, { ComponentType, ReactNode } from 'react' export type StoryConfig = { /** * Used to divide the resulting examples into sections. It should correspond * to an enumerated prop in the Component */ sectionProp?: keyof Props /** * Specifies the max number of examples that can exist in a single page * within a section */ maxExamplesPerPage?: number | ((sectionName: string) => number) /** * Specifies the total max number of examples. Default: 500 */ maxExamples?: number /** * An object with keys that correspond to the component props. Each key has a * corresponding value array. This array contains possible values for that prop. */ propValues?: Partial> /** * Prop keys to exclude from propValues. Useful when generating propValues with code. */ excludeProps?: (keyof Props)[] /** * The values returned by this function are passed to the component. * A function called with the prop combination for the current example. It * returns an object of props that will be passed into the `renderExample` * function as componentProps. */ getComponentProps?: (props: Props & Record) => Partial /** * The values returned by this function are passed to a `View` that wraps the * example. * A function called with the prop combination for the current example. It * returns an object of props that will be passed into the `renderExample` * function as exampleProps. */ getExampleProps?: (props: Props & Record) => Record /** * A function called with the examples and index for the current page of * examples. It returns an object of parameters/metadata for that page of * examples (e.g. to be passed in to a visual regression tool like chromatic). */ getParameters?: (params: ExamplesPage) => { [key: string]: any delay?: number disable?: boolean } filter?: (props: Props) => boolean } type ExampleSection = { sectionName: string propName: keyof Props propValue: string pages: ExamplesPage[] } export type ExamplesPage = { examples: Example[] index: number renderExample?: (exampleProps: Example) => ReactNode parameters?: Record } export type Example = { Component: ComponentType componentProps: Partial exampleProps: Record // actually Partial key: string } /** * Generates examples for the given component based on the given configuration. * @param Component A React component * @param config A configuration object (stored in xy.examples.jsx files in InstUI) * @returns Array of examples broken into sections and pages if configured to do so. * @module generateComponentExamples * @private * */ export function generateComponentExamples( Component: ComponentType, config: StoryConfig ) { const { sectionProp, excludeProps, filter } = config const PROPS_CACHE: string[] = [] const sections: ExampleSection[] = [] const maxExamples = config.maxExamples ? config.maxExamples : 500 let exampleCount = 0 let propValues: Partial> = {} const getParameters = (page: ExamplesPage) => { const examples = page.examples const index = page.index let parameters = {} if (typeof config.getParameters === 'function') { parameters = { ...config.getParameters({ examples, index }) } } return parameters } /** * Merges the auto-generated props with ones in the examples files specified * by the `getComponentProps()` method; props from the example files have * priority */ const mergeComponentPropsFromConfig = (props: Props) => { let componentProps = props // TODO this code is so complicated because getComponentProps(props) can return // different values based on its props parameter. // If it would always return the same thing then we could reduce the // number of combinations generated by generatePropCombinations() by // getComponentProps() reducing some to 1 value, it would also remove the // need of PROPS_CACHE and duplicate checks. // InstUI is not using the 'props' param of getComponentProps(), but others are if (typeof config.getComponentProps === 'function') { componentProps = { ...componentProps, ...config.getComponentProps(props) } } return componentProps } const getExampleProps = (props: Props) => { let exampleProps: Record = {} if (typeof config.getExampleProps === 'function') { exampleProps = { ...config.getExampleProps(props) } } return exampleProps } const addPage = (section: ExampleSection) => { const page: ExamplesPage = { examples: [], index: section.pages.length } section.pages.push(page) return page } const addExample = (sectionName: string, example: Example) => { let section = sections.find( (section) => section.sectionName === sectionName ) if (!section) { section = { sectionName: sectionName, propName: sectionProp!, propValue: sectionName, pages: [] } sections.push(section) } let page = section.pages[section.pages.length - 1] let { maxExamplesPerPage } = config if (typeof maxExamplesPerPage === 'function') { maxExamplesPerPage = maxExamplesPerPage(sectionName) } if (!page) { page = addPage(section) } else if ( maxExamplesPerPage && page.examples.length % maxExamplesPerPage === 0 && page.examples.length > 0 ) { page = addPage(section) } page.examples.push(example) } // Serializes the given recursively, faster than JSON.stringify() const fastSerialize = (props: Props) => { const strArr: string[] = [] objToString(props, strArr) return strArr.join('') } const objToString = (currObject: any, currString: string[]) => { if (!currObject) { return } if (React.isValidElement(currObject)) { currString.push(JSON.stringify(currObject)) } else if (typeof currObject === 'object') { for (const [key, value] of Object.entries(currObject)) { currString.push(key) objToString(value, currString) } } else { currString.push(currObject) } } const maybeAddExample = (props: Props): void => { const componentProps = mergeComponentPropsFromConfig(props) const ignore = typeof filter === 'function' ? filter(componentProps) : false if (ignore) { return } const propsString = fastSerialize(componentProps) if (!PROPS_CACHE.includes(propsString)) { const key = nanoid() const exampleProps = getExampleProps(props) exampleCount++ if (exampleCount < maxExamples) { PROPS_CACHE.push(propsString) let sectionName = 'Examples' if (sectionProp && componentProps[sectionProp]) { sectionName = componentProps[sectionProp] as unknown as string } addExample(sectionName, { Component, componentProps, exampleProps, key }) } } } if (isEmpty(config.propValues)) { maybeAddExample({} as Props) } else { if (Array.isArray(excludeProps)) { ;(Object.keys(config.propValues) as (keyof Props)[]).forEach( (propName) => { if (!excludeProps.includes(propName)) { propValues[propName] = config.propValues![propName] } } ) } else { propValues = config.propValues } // eslint-disable-next-line no-console console.info( `Generating examples for ${Component.displayName} (${ Object.keys(propValues).length } props):`, propValues ) // TODO reconcile the differences between these files // generatePropCombinations should call getComponentProps and not do anything? const combos = generatePropCombinations(propValues as any).filter(Boolean) let index = 0 while (index < combos.length && exampleCount < maxExamples) { const combo = combos[index] if (combo) { maybeAddExample(combo as Props) index++ } } } if (exampleCount >= maxExamples) { console.error( `Too many examples for ${Component.displayName}! Add a filter to the config.` ) } // eslint-disable-next-line no-console console.info( `Generated ${exampleCount} examples for ${Component.displayName}` ) sections.forEach(({ pages }) => { pages.forEach((page) => { // eslint-disable-next-line no-param-reassign page.parameters = getParameters(page) }) }) return sections } function isEmpty( obj: unknown ): obj is null | undefined | Record { if (typeof obj !== 'object') return true for (const key in obj) { if (Object.hasOwnProperty.call(obj, key)) return false } return true } export default generateComponentExamples